Răsfoiți Sursa

refactor: simplify KeyManager by removing background worker and making it pull-based

This commit significantly simplifies the KeyManager architecture by removing
the background worker thread and message-passing system in favor of a simpler
pull-based model where keys are fetched on-demand and cached.

The previous KeyManager implementation had a complex architecture with:
- Background worker thread running a refresh loop
- Message passing via mpsc channels (MessageToWorker)
- Retry logic with polling (MAX_RETRY = 50, RETRY_SLEEP = 100ms)
- Task lifecycle management (JoinHandle, Drop implementation)

This added complexity without significant benefits for the wallet use case,
where keys are typically accessed on-demand rather than continuously.

The KeyManager now provides explicit methods for different use cases:

**`load(storage, client)`** - General purpose loading:
- Checks if cache is ready and returns immediately if so
- Falls back to HTTP fetch if cache not ready
- Spawns async task to persist to database

**`load_from_mint(storage, client)`** - Force fetch from mint:
- Always fetches fresh data from HTTP
- Updates cache and spawns persistence task

**`get_auth(storage, auth_client)`** - Auth-specific loading (feature =
"auth"):
- Fetches auth keysets using AuthMintConnector
- Separate `auth_status` tracking for auth vs. regular keysets

Database writes are now deferred using `spawn()`:

- `store_mint_keys()` saves mint info, keysets, and keys to storage
- Uses `storage_versions` to track which databases have been synced
- Prevents duplicate writes when cache version matches stored version
- Safe to defer because in-memory cache is source of truth

Updated wallet keysets methods to use new API:

- Methods now call `key_manager.load()` instead of polling cache
- Storage and client passed explicitly to KeyManager methods

1. **Simpler architecture**: No background threads, channels, or message passing
2. **Reduced code**: 322 fewer lines overall (511 added, 833 removed)
3. **Better control**: Callers decide when to fetch vs. use cache
4. **Less overhead**: No constant background refresh loop
5. **Easier testing**: Synchronous behavior easier to reason about
6. **WASM compatible**: No reliance on background threads
Cesar Rodas 2 săptămâni în urmă
părinte
comite
c9bca19cc4

+ 64 - 48
crates/cdk/src/wallet/auth/auth_wallet.rs

@@ -17,8 +17,8 @@ use crate::nuts::{
     Proofs, ProtectedEndpoint, State,
 };
 use crate::types::ProofInfo;
-use crate::wallet::key_manager::KeyManager;
 use crate::wallet::mint_connector::AuthHttpClient;
+use crate::wallet::mint_metadata_cache::MintMetadataCache;
 use crate::{Amount, Error, OidcClient};
 
 /// JWT Claims structure for decoding tokens
@@ -40,13 +40,13 @@ pub struct AuthWallet {
     pub mint_url: MintUrl,
     /// Storage backend
     pub localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-    /// Centralized key manager (lock-free cached key access)
-    pub key_manager: Arc<KeyManager>,
+    /// Mint metadata cache (lock-free cached access to keys, keysets, and mint info)
+    pub metadata_cache: Arc<MintMetadataCache>,
     /// Protected methods
     pub protected_endpoints: Arc<RwLock<HashMap<ProtectedEndpoint, AuthRequired>>>,
     /// Refresh token for auth
     refresh_token: Arc<RwLock<Option<String>>>,
-    client: Arc<dyn AuthMintConnector + Send + Sync>,
+    auth_client: Arc<dyn AuthMintConnector + Send + Sync>,
     /// OIDC client for authentication
     oidc_client: Arc<RwLock<Option<OidcClient>>>,
 }
@@ -57,7 +57,7 @@ impl AuthWallet {
         mint_url: MintUrl,
         cat: Option<AuthToken>,
         localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-        key_manager: Arc<KeyManager>,
+        metadata_cache: Arc<MintMetadataCache>,
         protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
         oidc_client: Option<OidcClient>,
     ) -> Self {
@@ -65,10 +65,10 @@ impl AuthWallet {
         Self {
             mint_url,
             localstore,
-            key_manager,
+            metadata_cache,
             protected_endpoints: Arc::new(RwLock::new(protected_endpoints)),
             refresh_token: Arc::new(RwLock::new(None)),
-            client: http_client,
+            auth_client: http_client,
             oidc_client: Arc::new(RwLock::new(oidc_client)),
         }
     }
@@ -76,7 +76,7 @@ impl AuthWallet {
     /// Get the current auth token
     #[instrument(skip(self))]
     pub async fn get_auth_token(&self) -> Result<AuthToken, Error> {
-        self.client.get_auth_token().await
+        self.auth_client.get_auth_token().await
     }
 
     /// Set a new auth token
@@ -103,7 +103,7 @@ impl AuthWallet {
                 if let Some(oidc) = self.oidc_client.read().await.as_ref() {
                     oidc.verify_cat(clear_token).await?;
                 }
-                self.client.set_auth_token(token).await
+                self.auth_client.set_auth_token(token).await
             }
             AuthToken::BlindAuth(_) => Err(Error::Custom(
                 "Cannot set blind auth token directly".to_string(),
@@ -169,55 +169,59 @@ impl AuthWallet {
     /// Query mint for current mint information
     #[instrument(skip(self))]
     pub async fn get_mint_info(&self) -> Result<Option<MintInfo>, Error> {
-        self.client.get_mint_info().await.map(Some).or(Ok(None))
+        self.auth_client
+            .get_mint_info()
+            .await
+            .map(Some)
+            .or(Ok(None))
     }
 
     /// Fetch keys for mint keyset
     ///
-    /// Returns keys from KeyManager cache if available.
-    /// If keys are not cached, triggers a refresh and waits briefly before checking again.
+    /// Returns keys from metadata cache if available, fetches from mint if not.
     #[instrument(skip(self))]
     pub async fn load_keyset_keys(&self, keyset_id: Id) -> Result<Keys, Error> {
-        Ok((*self.key_manager.get_keys(&keyset_id).await?).clone())
+        let metadata = self
+            .metadata_cache
+            .load_auth(&self.localstore, &self.auth_client)
+            .await?;
+        let active = metadata
+            .active_keysets
+            .iter()
+            .find(|x| x.unit == CurrencyUnit::Auth)
+            .cloned()
+            .ok_or(Error::NoActiveKeyset)?;
+
+        metadata
+            .keys
+            .get(&active.id)
+            .map(|x| (*(x.clone())).clone())
+            .ok_or(Error::NoActiveKeyset)
     }
 
-    /// Get blind auth keysets from KeyManager cache or trigger refresh if missing
+    /// Get blind auth keysets from metadata cache
     ///
-    /// First checks the KeyManager cache for keysets. If keysets are not cached,
-    /// triggers a refresh from the mint and waits briefly before checking again.
+    /// Checks the metadata cache for auth keysets. If cache is not populated,
+    /// fetches from the mint server and updates the cache.
     /// This is the main method for getting auth keysets in operations that can work offline
     /// but will fall back to online if needed.
     #[instrument(skip(self))]
     pub async fn load_mint_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
-        if let Ok(keysets) = self.key_manager.get_keysets().await {
-            let auth_keysets: Vec<KeySetInfo> = keysets
-                .into_iter()
-                .filter(|k| k.unit == CurrencyUnit::Auth)
-                .collect();
-            if !auth_keysets.is_empty() {
-                return Ok(auth_keysets);
-            }
-        }
-
-        Err(Error::UnknownKeySet)
-    }
-
-    /// Refresh blind auth keysets by fetching the latest from mint - always goes online
-    ///
-    /// This method triggers a KeyManager refresh which fetches the latest blind auth keyset
-    /// information from the mint. The KeyManager handles updating the cache and database.
-    /// Returns only the keysets with Auth currency unit. This is used when operations need
-    /// the most up-to-date keyset information.
-    #[instrument(skip(self))]
-    pub async fn refresh_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
-        tracing::debug!("Refreshing auth keysets via KeyManager");
-
-        let auth_keysets = self
-            .key_manager
-            .refresh()
-            .await?
-            .into_iter()
-            .filter(|k| k.unit == CurrencyUnit::Auth && k.active)
+        let metadata = self
+            .metadata_cache
+            .load_auth(&self.localstore, &self.auth_client)
+            .await?;
+
+        let auth_keysets = metadata
+            .keysets
+            .iter()
+            .filter_map(|(_, k)| {
+                if k.unit == CurrencyUnit::Auth {
+                    Some((*(k.clone())).clone())
+                } else {
+                    None
+                }
+            })
             .collect::<Vec<_>>();
 
         if !auth_keysets.is_empty() {
@@ -227,6 +231,18 @@ impl AuthWallet {
         }
     }
 
+    /// Refresh blind auth keysets by fetching the latest from mint
+    ///
+    /// Fetches the latest blind auth keyset information from the mint server,
+    /// updating the metadata cache and database. Returns only the keysets with
+    /// Auth currency unit. Use this when you need the most up-to-date keyset information.
+    #[instrument(skip(self))]
+    pub async fn refresh_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
+        tracing::debug!("Refreshing auth keysets from mint");
+
+        self.load_mint_keysets().await
+    }
+
     /// Get the first active blind auth keyset - always goes online
     ///
     /// This method always goes online to refresh keysets from the mint and then returns
@@ -297,7 +313,7 @@ impl AuthWallet {
             Some(auth) => match auth {
                 AuthRequired::Clear => {
                     tracing::trace!("Clear auth needed for request.");
-                    self.client.get_auth_token().await.map(Some)
+                    self.auth_client.get_auth_token().await.map(Some)
                 }
                 AuthRequired::Blind => {
                     tracing::trace!("Blind auth needed for request getting Auth proof.");
@@ -331,7 +347,7 @@ impl AuthWallet {
             self.get_mint_info().await?;
         }
 
-        let auth_token = self.client.get_auth_token().await?;
+        let auth_token = self.auth_client.get_auth_token().await?;
 
         match &auth_token {
             AuthToken::ClearAuth(cat) => {
@@ -397,7 +413,7 @@ impl AuthWallet {
             outputs: premint_secrets.blinded_messages(),
         };
 
-        let mint_res = self.client.post_mint_blind_auth(request).await?;
+        let mint_res = self.auth_client.post_mint_blind_auth(request).await?;
 
         let keys = self.load_keyset_keys(active_keyset_id).await?;
 

+ 30 - 35
crates/cdk/src/wallet/builder.rs

@@ -13,7 +13,7 @@ use crate::mint_url::MintUrl;
 use crate::nuts::CurrencyUnit;
 #[cfg(feature = "auth")]
 use crate::wallet::auth::AuthWallet;
-use crate::wallet::key_manager::KeyManager;
+use crate::wallet::mint_metadata_cache::MintMetadataCache;
 use crate::wallet::{HttpClient, MintConnector, SubscriptionManager, Wallet};
 
 /// Builder for creating a new [`Wallet`]
@@ -27,8 +27,8 @@ pub struct WalletBuilder {
     seed: Option<[u8; 64]>,
     use_http_subscription: bool,
     client: Option<Arc<dyn MintConnector + Send + Sync>>,
-    key_manager: Option<Arc<KeyManager>>,
-    key_managers: HashMap<MintUrl, Arc<KeyManager>>,
+    metadata_cache: Option<Arc<MintMetadataCache>>,
+    metadata_caches: HashMap<MintUrl, Arc<MintMetadataCache>>,
 }
 
 impl Default for WalletBuilder {
@@ -43,8 +43,8 @@ impl Default for WalletBuilder {
             seed: None,
             client: None,
             use_http_subscription: false,
-            key_manager: None,
-            key_managers: HashMap::new(),
+            metadata_cache: None,
+            metadata_caches: HashMap::new(),
         }
     }
 }
@@ -120,22 +120,25 @@ impl WalletBuilder {
         self
     }
 
-    /// Set a shared KeyManager
+    /// Set a shared MintMetadataCache
     ///
-    /// This allows multiple wallets to share the same KeyManager instance for
-    /// optimal performance and memory usage. If not provided, a new KeyManager
+    /// This allows multiple wallets to share the same metadata cache instance for
+    /// optimal performance and memory usage. If not provided, a new cache
     /// will be created for each wallet.
-    pub fn key_manager(mut self, key_manager: Arc<KeyManager>) -> Self {
-        self.key_manager = Some(key_manager);
+    pub fn metadata_cache(mut self, metadata_cache: Arc<MintMetadataCache>) -> Self {
+        self.metadata_cache = Some(metadata_cache);
         self
     }
 
-    /// Set a HashMap of KeyManagers for reusing across multiple wallets
+    /// Set a HashMap of MintMetadataCaches for reusing across multiple wallets
     ///
-    /// This allows the builder to reuse existing KeyManager instances or create new ones.
-    /// Useful when creating multiple wallets that share KeyManagers.
-    pub fn key_managers(mut self, key_managers: HashMap<MintUrl, Arc<KeyManager>>) -> Self {
-        self.key_managers = key_managers;
+    /// This allows the builder to reuse existing cache instances or create new ones.
+    /// Useful when creating multiple wallets that share metadata caches.
+    pub fn metadata_caches(
+        mut self,
+        metadata_caches: HashMap<MintUrl, Arc<MintMetadataCache>>,
+    ) -> Self {
+        self.metadata_caches = metadata_caches;
         self
     }
 
@@ -145,17 +148,13 @@ impl WalletBuilder {
         let mint_url = self.mint_url.clone().expect("Mint URL required");
         let localstore = self.localstore.clone().expect("Localstore required");
 
-        let key_manager = self.key_manager.clone().unwrap_or_else(|| {
-            // Check if we already have a KeyManager for this mint in the HashMap
-            if let Some(km) = self.key_managers.get(&mint_url) {
-                km.clone()
+        let metadata_cache = self.metadata_cache.clone().unwrap_or_else(|| {
+            // Check if we already have a cache for this mint in the HashMap
+            if let Some(cache) = self.metadata_caches.get(&mint_url) {
+                cache.clone()
             } else {
                 // Create a new one
-                Arc::new(KeyManager::new(
-                    mint_url.clone(),
-                    localstore.clone(),
-                    Arc::new(HttpClient::new(mint_url.clone(), None)),
-                ))
+                Arc::new(MintMetadataCache::new(mint_url.clone()))
             }
         });
 
@@ -163,7 +162,7 @@ impl WalletBuilder {
             mint_url,
             Some(AuthToken::ClearAuth(cat)),
             localstore,
-            key_manager,
+            metadata_cache,
             HashMap::new(),
             None,
         ));
@@ -202,17 +201,13 @@ impl WalletBuilder {
             }
         };
 
-        let key_manager = self.key_manager.unwrap_or_else(|| {
-            // Check if we already have a KeyManager for this mint in the HashMap
-            if let Some(km) = self.key_managers.get(&mint_url) {
-                km.clone()
+        let metadata_cache = self.metadata_cache.unwrap_or_else(|| {
+            // Check if we already have a cache for this mint in the HashMap
+            if let Some(cache) = self.metadata_caches.get(&mint_url) {
+                cache.clone()
             } else {
                 // Create a new one
-                Arc::new(KeyManager::new(
-                    mint_url.clone(),
-                    localstore.clone(),
-                    client.clone(),
-                ))
+                Arc::new(MintMetadataCache::new(mint_url.clone()))
             }
         });
 
@@ -220,7 +215,7 @@ impl WalletBuilder {
             mint_url,
             unit,
             localstore,
-            key_manager,
+            metadata_cache,
             target_proof_count: self.target_proof_count.unwrap_or(3),
             #[cfg(feature = "auth")]
             auth_wallet: Arc::new(RwLock::new(self.auth_wallet)),

+ 0 - 327
crates/cdk/src/wallet/key_manager/mod.rs

@@ -1,327 +0,0 @@
-//! Per-mint key management with in-memory caching
-//!
-//! Provides key management for individual mints with automatic background refresh
-//! and lock-free cache access. Keys are fetched from mint servers, cached in memory,
-//! and periodically updated without blocking wallet operations.
-//!
-//! # Architecture
-//!
-//! - **Per-mint cache**: Stores keysets and keys indexed by ID with atomic updates
-//! - **Background refresh**: Periodic 5-minute updates keep keys fresh
-//! - **Database fallback**: Loads from storage when cache misses or HTTP fails
-//!
-//! # Usage
-//!
-//! ```ignore
-//! let key_manager = Arc::new(KeyManager::new(mint_url, storage, client));
-//! let keys = key_manager.get_keys(&keyset_id).await?;
-//! ```
-
-use std::collections::HashMap;
-use std::fmt::Debug;
-use std::sync::Arc;
-use std::time::{Duration, Instant};
-
-use arc_swap::ArcSwap;
-use cdk_common::database::{self, WalletDatabase};
-use cdk_common::mint_url::MintUrl;
-use cdk_common::nuts::{KeySetInfo, Keys};
-use cdk_common::task::spawn;
-use cdk_common::MintInfo;
-use tokio::sync::mpsc;
-use tokio::task::JoinHandle;
-use worker::MessageToWorker;
-
-mod worker;
-
-use crate::nuts::Id;
-#[cfg(feature = "auth")]
-use crate::wallet::AuthHttpClient;
-use crate::wallet::MintConnector;
-use crate::Error;
-
-/// Refresh interval for background key refresh (5 minutes)
-const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(300);
-
-const MAX_RETRY: usize = 50;
-const RETRY_SLEEP: Duration = Duration::from_millis(100);
-
-/// Per-mint key cache
-///
-/// Stores all keyset and key data for a single mint. Updated atomically via ArcSwap.
-/// The `refresh_version` increments on each update to detect when cache has changed.
-#[derive(Clone, Debug)]
-pub(super) struct MintKeyCache {
-    /// If the cache is ready
-    pub is_ready: bool,
-
-    /// Mint info from server
-    pub mint_info: Option<MintInfo>,
-
-    /// All keysets by ID
-    pub keysets_by_id: HashMap<Id, Arc<KeySetInfo>>,
-
-    /// Active keysets for quick access
-    pub active_keysets: Vec<Arc<KeySetInfo>>,
-
-    /// All keys by keyset ID
-    pub keys_by_id: HashMap<Id, Arc<Keys>>,
-
-    /// Last refresh timestamp
-    pub last_refresh: Instant,
-
-    /// Cache generation (increments on each refresh)
-    pub refresh_version: u64,
-}
-
-impl MintKeyCache {
-    pub(super) fn empty() -> Self {
-        Self {
-            is_ready: false,
-            mint_info: None,
-            keysets_by_id: HashMap::new(),
-            active_keysets: Vec::new(),
-            keys_by_id: HashMap::new(),
-            last_refresh: Instant::now(),
-            refresh_version: 0,
-        }
-    }
-}
-
-/// Key manager for a single mint
-///
-/// Manages keys for a specific mint with in-memory caching and background refresh.
-/// Each KeyManager owns its background worker task.
-pub struct KeyManager {
-    /// Mint URL
-    mint_url: MintUrl,
-
-    /// Storage backend
-    #[allow(dead_code)]
-    storage: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-
-    /// Shared cache (atomic updates)
-    cache: Arc<ArcSwap<MintKeyCache>>,
-
-    /// Message sender to background worker
-    tx: mpsc::Sender<MessageToWorker>,
-
-    /// Background worker task handle
-    task: Option<JoinHandle<()>>,
-}
-
-impl std::fmt::Debug for KeyManager {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.debug_struct("KeyManager")
-            .field("mint_url", &self.mint_url)
-            .field("cache_ready", &self.cache.load().is_ready)
-            .finish()
-    }
-}
-
-impl Drop for KeyManager {
-    fn drop(&mut self) {
-        tracing::debug!("Dropping KeyManager for {}", self.mint_url);
-        self.tx
-            .try_send(MessageToWorker::Stop)
-            .inspect_err(|e| {
-                tracing::error!("Failed to send Stop message for {}: {}", self.mint_url, e)
-            })
-            .ok();
-        if let Some(task) = self.task.take() {
-            task.abort();
-        }
-    }
-}
-
-impl KeyManager {
-    /// Create a new KeyManager with default 5-minute refresh interval
-    pub fn new(
-        mint_url: MintUrl,
-        storage: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-        client: Arc<dyn MintConnector + Send + Sync>,
-    ) -> Self {
-        Self::with_refresh_interval(mint_url, storage, client, DEFAULT_REFRESH_INTERVAL)
-    }
-
-    /// Create a new KeyManager with custom refresh interval
-    pub fn with_refresh_interval(
-        mint_url: MintUrl,
-        storage: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-        client: Arc<dyn MintConnector + Send + Sync>,
-        refresh_interval: Duration,
-    ) -> Self {
-        tracing::debug!("Creating KeyManager for mint: {}", mint_url);
-
-        let (tx, rx) = mpsc::channel(1_000);
-        let cache = Arc::new(ArcSwap::from_pointee(MintKeyCache::empty()));
-
-        #[cfg(feature = "auth")]
-        let auth_client = Arc::new(AuthHttpClient::new(mint_url.clone(), None));
-
-        let task = {
-            let mint_url_clone = mint_url.clone();
-            let client_clone = client.clone();
-            #[cfg(feature = "auth")]
-            let auth_client_clone = auth_client.clone();
-            let storage_clone = storage.clone();
-            let cache_clone = cache.clone();
-
-            spawn(worker::refresh_loop(
-                mint_url_clone,
-                client_clone,
-                #[cfg(feature = "auth")]
-                auth_client_clone,
-                storage_clone,
-                cache_clone,
-                rx,
-                refresh_interval,
-            ))
-        };
-
-        // Trigger initial sync (best effort - log if it fails)
-        if let Err(e) = tx.try_send(MessageToWorker::FetchMint) {
-            tracing::error!(
-                "Failed to send initial FetchMint message for {}: {}",
-                mint_url,
-                e
-            );
-        }
-
-        Self {
-            mint_url,
-            storage,
-            cache,
-            tx,
-            task: Some(task),
-        }
-    }
-
-    /// Get the mint URL for this KeyManager
-    pub fn mint_url(&self) -> &MintUrl {
-        &self.mint_url
-    }
-
-    /// Send a message to the background refresh task (best effort)
-    fn send_message(&self, msg: MessageToWorker) {
-        if let Err(e) = self.tx.try_send(msg) {
-            tracing::error!(
-                "Failed to send message to refresh task for {} (closed: {}): {}",
-                self.mint_url,
-                self.tx.is_closed(),
-                e
-            );
-        }
-    }
-
-    /// Get keys for a keyset (cache-first with automatic refresh)
-    ///
-    /// Returns keys from cache if available. If not cached, triggers a refresh
-    /// and waits up to 5 seconds for the keys to arrive.
-    pub async fn get_keys(&self, keyset_id: &Id) -> Result<Arc<Keys>, Error> {
-        for _ in 0..MAX_RETRY {
-            let cache = self.cache.load();
-            if cache.is_ready {
-                return cache
-                    .keys_by_id
-                    .get(keyset_id)
-                    .cloned()
-                    .ok_or(Error::UnknownKeySet);
-            }
-            tokio::time::sleep(RETRY_SLEEP).await;
-        }
-
-        Err(Error::UnknownKeySet)
-    }
-
-    /// Get keyset info by ID (cache-first with automatic refresh)
-    pub async fn get_keyset_by_id(&self, keyset_id: &Id) -> Result<Arc<KeySetInfo>, Error> {
-        for _ in 0..MAX_RETRY {
-            let cache = self.cache.load();
-            if cache.is_ready {
-                return cache
-                    .keysets_by_id
-                    .get(keyset_id)
-                    .cloned()
-                    .ok_or(Error::UnknownKeySet);
-            }
-            tokio::time::sleep(RETRY_SLEEP).await;
-        }
-
-        Err(Error::UnknownKeySet)
-    }
-
-    /// Get all keysets for the mint (cache-first with automatic refresh)
-    pub async fn get_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
-        for _ in 0..MAX_RETRY {
-            let cache = self.cache.load();
-            if cache.is_ready {
-                let keysets: Vec<KeySetInfo> = cache
-                    .keysets_by_id
-                    .values()
-                    .map(|ks| (**ks).clone())
-                    .collect();
-                return if keysets.is_empty() {
-                    Err(Error::UnknownKeySet)
-                } else {
-                    Ok(keysets)
-                };
-            }
-
-            tokio::time::sleep(RETRY_SLEEP).await;
-        }
-
-        Err(Error::UnknownKeySet)
-    }
-
-    /// Get all active keysets for the mint (cache-first with automatic refresh)
-    pub async fn get_active_keysets(&self) -> Result<Vec<Arc<KeySetInfo>>, Error> {
-        for _ in 0..MAX_RETRY {
-            let cache = self.cache.load();
-            if cache.is_ready {
-                return Ok(cache.active_keysets.clone());
-            }
-            tokio::time::sleep(RETRY_SLEEP).await;
-        }
-
-        Err(Error::UnknownKeySet)
-    }
-
-    /// Trigger a refresh and wait for it to complete
-    ///
-    /// Sends a refresh message to the background task and waits up to 5 seconds
-    /// for the cache to be updated with a newer version.
-    pub async fn refresh(&self) -> Result<Vec<KeySetInfo>, Error> {
-        let last_version = self.cache.load().refresh_version;
-
-        self.send_message(MessageToWorker::FetchMint);
-
-        for _ in 0..MAX_RETRY {
-            if let Some(keysets) = {
-                let cache = self.cache.load();
-                if last_version < cache.refresh_version {
-                    Some(
-                        cache
-                            .keysets_by_id
-                            .values()
-                            .map(|ks| (**ks).clone())
-                            .collect::<Vec<_>>(),
-                    )
-                } else {
-                    None
-                }
-            } {
-                return Ok(keysets);
-            }
-
-            tokio::time::sleep(RETRY_SLEEP).await;
-        }
-
-        Err(Error::UnknownKeySet)
-    }
-
-    /// Trigger a refresh (non-blocking)
-    pub fn refresh_now(&self) {
-        self.send_message(MessageToWorker::FetchMint);
-    }
-}

+ 0 - 444
crates/cdk/src/wallet/key_manager/worker.rs

@@ -1,444 +0,0 @@
-//! Background worker for fetching and refreshing mint keys
-
-use std::fmt::Debug;
-use std::sync::Arc;
-use std::time::Duration;
-
-use arc_swap::ArcSwap;
-use cdk_common::database::{self, WalletDatabase};
-use cdk_common::mint_url::MintUrl;
-use cdk_common::KeySet;
-use tokio::time::sleep;
-
-use super::MintKeyCache;
-use crate::nuts::Id;
-#[cfg(feature = "auth")]
-use crate::wallet::AuthMintConnector;
-use crate::wallet::MintConnector;
-use crate::Error;
-
-/// Messages for the background refresh task
-#[derive(Debug)]
-pub(super) enum MessageToWorker {
-    /// Stop the refresh task
-    Stop,
-
-    /// Fetch keys from the mint immediately
-    FetchMint,
-}
-
-/// Load a specific keyset from database or HTTP
-///
-/// First checks the database for the keyset. If not found,
-/// fetches from the mint server via HTTP and persists to database.
-async fn load_keyset_from_db_or_http(
-    mint_url: &MintUrl,
-    client: &Arc<dyn MintConnector + Send + Sync>,
-    storage: &Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-    keyset_id: &Id,
-) -> Result<KeySet, Error> {
-    // Try database first
-    if let Some(keys) = storage.get_keys(keyset_id).await? {
-        tracing::debug!("Loaded keyset {} from database for {}", keyset_id, mint_url);
-
-        // Get keyset info to construct KeySet
-        if let Some(keyset_info) = storage.get_keyset_by_id(keyset_id).await? {
-            return Ok(KeySet {
-                id: *keyset_id,
-                unit: keyset_info.unit,
-                final_expiry: keyset_info.final_expiry,
-                keys,
-            });
-        }
-    }
-
-    // Not in database, fetch from HTTP
-    tracing::debug!(
-        "Keyset {} not in database, fetching from mint server for {}",
-        keyset_id,
-        mint_url
-    );
-
-    let keyset = client.get_mint_keyset(*keyset_id).await?;
-    keyset.verify_id()?;
-
-    // Persist to database
-    storage.add_keys(keyset.clone()).await.inspect_err(|e| {
-        tracing::warn!(
-            "Failed to persist keyset {} for {}: {}",
-            keyset_id,
-            mint_url,
-            e
-        )
-    })?;
-
-    tracing::debug!("Loaded keyset {} from HTTP for {}", keyset_id, mint_url);
-
-    Ok(keyset)
-}
-
-/// Load cached mint data from storage backend
-///
-/// Loads keysets and keys from storage.
-/// Marks cache as ready only if mint_info was found.
-///
-/// Returns a MintKeyCache that may or may not be ready depending on what was found.
-async fn load_cache_from_storage(
-    mint_url: &MintUrl,
-    storage: &Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-) -> Result<MintKeyCache, Error> {
-    tracing::debug!("Loading cache from storage for {}", mint_url);
-
-    let mut cache = MintKeyCache::empty();
-
-    // Load mint info
-    match storage.get_mint(mint_url.clone()).await {
-        Ok(Some(mint_info)) => {
-            tracing::debug!("Found mint info in storage for {}", mint_url);
-            cache.mint_info = Some(mint_info);
-        }
-        Ok(None) => {
-            tracing::debug!("No mint info in storage for {}", mint_url);
-        }
-        Err(e) => {
-            tracing::warn!(
-                "Error loading mint info from storage for {}: {}",
-                mint_url,
-                e
-            );
-        }
-    }
-
-    // Load keysets
-    match storage.get_mint_keysets(mint_url.clone()).await {
-        Ok(Some(keysets)) if !keysets.is_empty() => {
-            tracing::debug!(
-                "Loaded {} keysets from storage for {}",
-                keysets.len(),
-                mint_url
-            );
-
-            for keyset in keysets {
-                cache
-                    .keysets_by_id
-                    .insert(keyset.id, Arc::new(keyset.clone()));
-
-                if keyset.active {
-                    cache.active_keysets.push(Arc::new(keyset));
-                }
-            }
-        }
-        Ok(_) => {
-            tracing::debug!("No keysets in storage for {}", mint_url);
-        }
-        Err(e) => {
-            tracing::warn!("Error loading keysets from storage for {}: {}", mint_url, e);
-        }
-    }
-
-    // Load keys for each keyset
-    for keyset_id in cache.keysets_by_id.keys() {
-        match storage.get_keys(keyset_id).await {
-            Ok(Some(keys)) => {
-                cache.keys_by_id.insert(*keyset_id, Arc::new(keys));
-            }
-            Ok(None) => {
-                tracing::debug!(
-                    "No keys for keyset {} in storage for {}",
-                    keyset_id,
-                    mint_url
-                );
-            }
-            Err(e) => {
-                tracing::warn!(
-                    "Error loading keys for keyset {} from storage for {}: {}",
-                    keyset_id,
-                    mint_url,
-                    e
-                );
-            }
-        }
-    }
-
-    // Only mark ready if we have mint_info
-    cache.is_ready = cache.mint_info.is_some();
-
-    Ok(cache)
-}
-
-/// Persist the current cache to storage
-async fn write_cache_to_storage(
-    mint_url: &MintUrl,
-    storage: &Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-    cache: &Arc<ArcSwap<MintKeyCache>>,
-) {
-    let cache_snapshot = cache.load();
-
-    // Save mint info
-    if let Some(mint_info) = &cache_snapshot.mint_info {
-        storage
-            .add_mint(mint_url.clone(), Some(mint_info.clone()))
-            .await
-            .inspect_err(|e| tracing::warn!("Failed to save mint info for {}: {}", mint_url, e))
-            .ok();
-    }
-
-    // Save keysets (via add_mint_keysets which takes mint_url and keysets)
-    let keysets: Vec<_> = cache_snapshot
-        .keysets_by_id
-        .values()
-        .map(|ks| (**ks).clone())
-        .collect();
-
-    if !keysets.is_empty() {
-        storage
-            .add_mint_keysets(mint_url.clone(), keysets)
-            .await
-            .inspect_err(|e| tracing::warn!("Failed to save keysets for {}: {}", mint_url, e))
-            .ok();
-    }
-
-    // Save keys
-    for (keyset_id, keys) in &cache_snapshot.keys_by_id {
-        if let Some(keyset_info) = cache_snapshot.keysets_by_id.get(keyset_id) {
-            let keyset = KeySet {
-                id: *keyset_id,
-                unit: keyset_info.unit.clone(),
-                final_expiry: keyset_info.final_expiry,
-                keys: (**keys).clone(),
-            };
-
-            storage
-                .add_keys(keyset)
-                .await
-                .inspect_err(|e| {
-                    tracing::warn!(
-                        "Failed to save keys for keyset {} for {}: {}",
-                        keyset_id,
-                        mint_url,
-                        e
-                    )
-                })
-                .ok();
-        }
-    }
-}
-
-/// Try to load cache from storage and update if successful
-async fn try_load_cache_from_storage(
-    mint_url: &MintUrl,
-    storage: &Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-    cache: &Arc<ArcSwap<MintKeyCache>>,
-) {
-    match load_cache_from_storage(mint_url, storage).await {
-        Ok(loaded_cache) if loaded_cache.is_ready => {
-            tracing::info!("Successfully loaded cache from storage for {}", mint_url);
-            let old_version = cache.load().refresh_version;
-            let mut new_cache = loaded_cache;
-            new_cache.refresh_version = old_version + 1;
-            cache.store(Arc::new(new_cache));
-        }
-        Ok(_) => {
-            tracing::debug!("Storage cache for {} exists but not ready", mint_url);
-        }
-        Err(e) => {
-            tracing::warn!("Failed to load cache from storage for {}: {}", mint_url, e);
-        }
-    }
-}
-
-/// Fetch fresh mint data from HTTP and update cache
-///
-/// Steps:
-/// 1. Fetches mint info from server
-/// 2. Fetches keyset list
-/// 3. Fetches keys for each keyset
-/// 4. Updates in-memory cache atomically
-/// 5. Persists all data to storage
-async fn fetch_mint_data_from_http(
-    mint_url: &MintUrl,
-    client: &Arc<dyn MintConnector + Send + Sync>,
-    #[cfg(feature = "auth")] auth_client: &Arc<dyn AuthMintConnector + Send + Sync>,
-    storage: &Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-    cache: &Arc<ArcSwap<MintKeyCache>>,
-) {
-    tracing::debug!("Fetching mint data from HTTP for {}", mint_url);
-
-    let mut new_cache = MintKeyCache::empty();
-
-    // Fetch mint info
-    match client.get_mint_info().await {
-        Ok(mint_info) => {
-            tracing::debug!("Fetched mint info for {}", mint_url);
-            new_cache.mint_info = Some(mint_info);
-        }
-        Err(e) => {
-            tracing::error!("Failed to fetch mint info for {}: {}", mint_url, e);
-            return;
-        }
-    }
-
-    // Fetch keysets
-    let keysets = match client.get_mint_keysets().await {
-        Ok(response) => response.keysets,
-        Err(e) => {
-            tracing::error!("Failed to fetch keysets for {}: {}", mint_url, e);
-            return;
-        }
-    };
-
-    #[cfg(feature = "auth")]
-    let keysets = match auth_client.get_mint_blind_auth_keysets().await {
-        Ok(response) => {
-            let mut keysets = keysets;
-            keysets.extend(response.keysets);
-            keysets
-        }
-        Err(e) => {
-            tracing::error!("Failed to fetch keysets for {}: {}", mint_url, e);
-            keysets
-        }
-    };
-
-    tracing::debug!("Fetched {} keysets for {}", keysets.len(), mint_url);
-
-    // Fetch keys for each keyset
-    for keyset_info in keysets {
-        let keyset_arc = Arc::new(keyset_info.clone());
-        new_cache
-            .keysets_by_id
-            .insert(keyset_info.id, keyset_arc.clone());
-
-        if keyset_info.active {
-            new_cache.active_keysets.push(keyset_arc);
-        }
-
-        // Load keys (from DB or HTTP)
-        if let Ok(keyset) =
-            load_keyset_from_db_or_http(mint_url, client, storage, &keyset_info.id).await
-        {
-            new_cache
-                .keys_by_id
-                .insert(keyset_info.id, Arc::new(keyset.keys));
-        } else {
-            tracing::warn!(
-                "Failed to load keys for keyset {} for {}",
-                keyset_info.id,
-                mint_url
-            );
-        }
-    }
-
-    // Update cache atomically
-    let old_version = cache.load().refresh_version;
-    new_cache.is_ready = true;
-    new_cache.last_refresh = std::time::Instant::now();
-    new_cache.refresh_version = old_version + 1;
-
-    tracing::info!(
-        "Updating cache for {} with {} keysets (version {})",
-        mint_url,
-        new_cache.keysets_by_id.len(),
-        new_cache.refresh_version
-    );
-
-    let cache_arc = Arc::new(new_cache);
-    cache.store(cache_arc.clone());
-
-    // Persist to storage
-    write_cache_to_storage(mint_url, storage, cache).await;
-}
-
-/// Execute a single refresh task
-///
-/// Calls fetch_mint_data_from_http and handles any errors
-async fn refresh_mint_task(
-    mint_url: MintUrl,
-    client: Arc<dyn MintConnector + Send + Sync>,
-    #[cfg(feature = "auth")] auth_client: Arc<dyn AuthMintConnector + Send + Sync>,
-    storage: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-    cache: Arc<ArcSwap<MintKeyCache>>,
-) {
-    fetch_mint_data_from_http(
-        &mint_url,
-        &client,
-        #[cfg(feature = "auth")]
-        &auth_client,
-        &storage,
-        &cache,
-    )
-    .await;
-}
-
-/// Background refresh loop for a single mint
-///
-/// Listens for messages and periodically refreshes mint data.
-/// Runs until a Stop message is received.
-pub(super) async fn refresh_loop(
-    mint_url: MintUrl,
-    client: Arc<dyn MintConnector + Send + Sync>,
-    #[cfg(feature = "auth")] auth_client: Arc<dyn AuthMintConnector + Send + Sync>,
-    storage: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-    cache: Arc<ArcSwap<MintKeyCache>>,
-    mut rx: tokio::sync::mpsc::Receiver<MessageToWorker>,
-    refresh_interval: Duration,
-) {
-    tracing::debug!(
-        "Starting refresh loop for {} (interval: {:?})",
-        mint_url,
-        refresh_interval
-    );
-
-    // Try to load from storage first
-    try_load_cache_from_storage(&mint_url, &storage, &cache).await;
-
-    // Perform initial refresh from HTTP
-    tracing::debug!("Performing initial HTTP refresh for {}", mint_url);
-    refresh_mint_task(
-        mint_url.clone(),
-        client.clone(),
-        #[cfg(feature = "auth")]
-        auth_client.clone(),
-        storage.clone(),
-        cache.clone(),
-    )
-    .await;
-
-    // Main event loop
-    loop {
-        tokio::select! {
-            Some(msg) = rx.recv() => {
-                match msg {
-                    MessageToWorker::Stop => {
-                        tracing::debug!("Stopping refresh loop for {}", mint_url);
-                        break;
-                    }
-                    MessageToWorker::FetchMint => {
-                        tracing::debug!("Manual refresh triggered for {}", mint_url);
-                        refresh_mint_task(
-                            mint_url.clone(),
-                            client.clone(),
-                            #[cfg(feature = "auth")]
-                            auth_client.clone(),
-                            storage.clone(),
-                            cache.clone(),
-                        ).await;
-                    }
-                }
-            }
-            _ = sleep(refresh_interval) => {
-                tracing::debug!("Time to refresh mint: {}", mint_url);
-                refresh_mint_task(
-                    mint_url.clone(),
-                    client.clone(),
-                    #[cfg(feature = "auth")]
-                    auth_client.clone(),
-                    storage.clone(),
-                    cache.clone(),
-                ).await;
-            }
-        }
-    }
-
-    tracing::debug!("Refresh loop ended for {}", mint_url);
-}

+ 76 - 49
crates/cdk/src/wallet/keysets.rs

@@ -10,43 +10,62 @@ use crate::{Error, Wallet};
 impl Wallet {
     /// Load keys for mint keyset
     ///
-    /// Returns keys from KeyManager cache if available.
-    /// If keys are not cached, triggers a refresh and waits briefly before checking again.
+    /// Returns keys from metadata cache if available.
+    /// If keys are not cached, fetches from mint server.
     #[instrument(skip(self))]
     pub async fn load_keyset_keys(&self, keyset_id: Id) -> Result<Keys, Error> {
-        Ok((*self.key_manager.get_keys(&keyset_id).await?).clone())
+        self.metadata_cache
+            .load(&self.localstore, &self.client)
+            .await?
+            .keys
+            .get(&keyset_id)
+            .map(|x| (*x.clone()).clone())
+            .ok_or(Error::UnknownKeySet)
     }
 
-    /// Get keysets from KeyManager cache or trigger refresh if missing
+    /// Get keysets from metadata cache or fetch if missing
     ///
-    /// First checks the KeyManager cache for keysets. If keysets are not cached,
-    /// triggers a refresh from the mint and waits briefly before checking again.
+    /// First checks the metadata cache for keysets. If keysets are not cached,
+    /// fetches from the mint server and updates the cache.
     /// This is the main method for getting keysets in token operations that can work offline
     /// but will fall back to online if needed.
     #[instrument(skip(self))]
     pub async fn load_mint_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
         Ok(self
-            .key_manager
-            .get_keysets()
+            .metadata_cache
+            .load(&self.localstore, &self.client)
             .await?
-            .into_iter()
-            .filter(|x| x.unit == self.unit && x.active)
+            .keysets
+            .iter()
+            .filter_map(|(_, keyset)| {
+                if keyset.unit == self.unit && keyset.active {
+                    Some((*keyset.clone()).clone())
+                } else {
+                    None
+                }
+            })
             .collect::<Vec<_>>())
     }
 
-    /// Get keysets from KeyManager cache only - pure offline operation
+    /// Get keysets from metadata cache (may fetch if not populated)
     ///
-    /// Only checks the KeyManager cache for keysets. If keysets are not cached,
-    /// returns an error without going online. This is used for operations that must remain
-    /// offline and rely on previously cached keyset data.
+    /// Checks the metadata cache for keysets. If cache is not populated,
+    /// fetches from mint and updates cache. Returns error if no active keysets found.
     #[instrument(skip(self))]
     pub async fn get_mint_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
         let keysets = self
-            .key_manager
-            .get_keysets()
+            .metadata_cache
+            .load(&self.localstore, &self.client)
             .await?
-            .into_iter()
-            .filter(|k| k.unit == self.unit && k.active)
+            .keysets
+            .iter()
+            .filter_map(|(_, keyset)| {
+                if keyset.unit == self.unit && keyset.active {
+                    Some((*keyset.clone()).clone())
+                } else {
+                    None
+                }
+            })
             .collect::<Vec<_>>();
 
         if !keysets.is_empty() {
@@ -56,21 +75,28 @@ impl Wallet {
         }
     }
 
-    /// Refresh keysets by fetching the latest from mint - always goes online
+    /// Refresh keysets by fetching the latest from mint - always fetches fresh data
     ///
-    /// This method triggers a KeyManager refresh which fetches the latest keyset
-    /// information from the mint. The KeyManager handles updating the cache and database.
-    /// This is used when operations need the most up-to-date keyset information.
+    /// Forces a fresh fetch of keyset information from the mint server,
+    /// updating the metadata cache and database. Use this when you need
+    /// the most up-to-date keyset information.
     #[instrument(skip(self))]
     pub async fn refresh_keysets(&self) -> Result<KeySetInfos, Error> {
-        tracing::debug!("Refreshing keysets via KeyManager");
+        tracing::debug!("Refreshing keysets from mint");
 
         let keysets = self
-            .key_manager
-            .refresh()
+            .metadata_cache
+            .load_from_mint(&self.localstore, &self.client)
             .await?
-            .into_iter()
-            .filter(|k| k.unit == self.unit && k.active)
+            .keysets
+            .iter()
+            .filter_map(|(_, keyset)| {
+                if keyset.unit == self.unit && keyset.active {
+                    Some((*keyset.clone()).clone())
+                } else {
+                    None
+                }
+            })
             .collect::<Vec<_>>();
 
         if !keysets.is_empty() {
@@ -80,11 +106,11 @@ impl Wallet {
         }
     }
 
-    /// Get the active keyset with the lowest fees - always goes online
+    /// Get the active keyset with the lowest fees - fetches fresh data from mint
     ///
-    /// This method always goes online to refresh keysets from the mint and then returns
-    /// the active keyset with the minimum input fees. Use this when you need the most
-    /// up-to-date keyset information for operations.
+    /// Forces a fresh fetch of keysets from the mint and returns the active keyset
+    /// with the minimum input fees. Use this when you need the most up-to-date
+    /// keyset information for operations.
     #[instrument(skip(self))]
     pub async fn fetch_active_keyset(&self) -> Result<KeySetInfo, Error> {
         self.refresh_keysets()
@@ -95,32 +121,34 @@ impl Wallet {
             .ok_or(Error::NoActiveKeyset)
     }
 
-    /// Get the active keyset with the lowest fees from KeyManager cache - offline operation
+    /// Get the active keyset with the lowest fees from cache
     ///
-    /// Returns the active keyset with minimum input fees from the KeyManager cache.
-    /// This is an offline operation that does not contact the mint. If no keysets are cached,
-    /// returns an error. Use this for offline operations or when you want to avoid network calls.
+    /// Returns the active keyset with minimum input fees from the metadata cache.
+    /// Uses cached data if available, fetches from mint if cache not populated.
     #[instrument(skip(self))]
     pub async fn get_active_keyset(&self) -> Result<KeySetInfo, Error> {
-        let active_keysets = self.key_manager.get_active_keysets().await?;
-
-        active_keysets
-            .into_iter()
+        self.metadata_cache
+            .load(&self.localstore, &self.client)
+            .await?
+            .active_keysets
+            .iter()
             .min_by_key(|k| k.input_fee_ppk)
-            .map(|ks| (*ks).clone())
+            .map(|ks| (**ks).clone())
             .ok_or(Error::NoActiveKeyset)
     }
 
-    /// Get keyset fees and amounts for mint from KeyManager cache - offline operation
+    /// Get keyset fees and amounts for all keysets from metadata cache
     ///
     /// Returns a HashMap of keyset IDs to their input fee rates (per-proof-per-thousand)
-    /// from the KeyManager cache. This is an offline operation that does not contact the mint.
-    /// If no keysets are cached, returns an error.
+    /// and available amounts. Uses cached data if available, fetches from mint if not.
     pub async fn get_keyset_fees_and_amounts(&self) -> Result<KeysetFeeAndAmounts, Error> {
-        let keysets = self.key_manager.get_keysets().await?;
+        let metadata = self
+            .metadata_cache
+            .load(&self.localstore, &self.client)
+            .await?;
 
         let mut fees = HashMap::new();
-        for keyset in keysets {
+        for keyset in metadata.keysets.values() {
             let keys = self.load_keyset_keys(keyset.id).await?;
             fees.insert(
                 keyset.id,
@@ -137,11 +165,10 @@ impl Wallet {
         Ok(fees)
     }
 
-    /// Get keyset fees and amounts for mint by keyset id from local database only - offline operation
+    /// Get keyset fees and amounts for a specific keyset ID
     ///
-    /// Returns the input fee rate (per-proof-per-thousand) for a specific keyset ID from
-    /// cached keysets in the local database. This is an offline operation that does not
-    /// contact the mint. If the keyset is not found locally, returns an error.
+    /// Returns the input fee rate (per-proof-per-thousand) and available amounts
+    /// for a specific keyset. Uses cached data if available, fetches from mint if not.
     pub async fn get_keyset_fees_and_amounts_by_id(
         &self,
         keyset_id: Id,

+ 540 - 0
crates/cdk/src/wallet/mint_metadata_cache.rs

@@ -0,0 +1,540 @@
+//! Per-mint cryptographic key and metadata cache
+//!
+//! Provides on-demand fetching and caching of mint metadata (info, keysets, and keys)
+//! with atomic in-memory cache updates and deferred database persistence.
+//!
+//! # Architecture
+//!
+//! - **Pull-based loading**: Keys fetched on-demand from mint HTTP API
+//! - **Atomic cache**: Single `MintMetadata` snapshot updated via `ArcSwap`
+//! - **Deferred persistence**: Database writes happen asynchronously after cache update
+//! - **Multi-database support**: Tracks sync status per storage instance via pointer identity
+//!
+//! # Usage
+//!
+//! ```ignore
+//! // Create manager (cheap, no I/O)
+//! let manager = Arc::new(MintMetadataCache::new(mint_url));
+//!
+//! // Load metadata (returns cached if available, fetches if not)
+//! let metadata = manager.load(&storage, &client).await?;
+//! let keys = metadata.keys.get(&keyset_id).ok_or(Error::UnknownKeySet)?;
+//!
+//! // Force refresh from mint
+//! let fresh = manager.load_from_mint(&storage, &client).await?;
+//! ```
+
+use std::collections::HashMap;
+use std::fmt::Debug;
+use std::sync::Arc;
+use std::time::Instant;
+
+use arc_swap::ArcSwap;
+use cdk_common::database::{self, WalletDatabase};
+use cdk_common::mint_url::MintUrl;
+use cdk_common::nuts::{KeySetInfo, Keys};
+use cdk_common::parking_lot::RwLock;
+use cdk_common::task::spawn;
+use cdk_common::{KeySet, MintInfo};
+
+use crate::nuts::Id;
+#[cfg(feature = "auth")]
+use crate::wallet::AuthMintConnector;
+use crate::wallet::MintConnector;
+use crate::Error;
+
+/// Metadata freshness and versioning information
+///
+/// Tracks when data was last fetched and which version is currently cached.
+/// Used to determine if cache is ready and if database sync is needed.
+#[derive(Clone, Debug)]
+struct FreshnessStatus {
+    /// Whether this data has been successfully fetched at least once
+    is_populated: bool,
+
+    /// When this data was last fetched from the mint
+    last_fetched_at: Instant,
+
+    /// Monotonically increasing version number (for database sync tracking)
+    version: usize,
+}
+
+impl Default for FreshnessStatus {
+    fn default() -> Self {
+        Self {
+            is_populated: false,
+            last_fetched_at: Instant::now(),
+            version: 0,
+        }
+    }
+}
+
+/// Complete metadata snapshot for a single mint
+///
+/// Contains all cryptographic keys, keyset metadata, and mint information
+/// fetched from a mint server. This struct is atomically swapped as a whole
+/// to ensure readers always see a consistent view.
+///
+/// Cloning is cheap due to `Arc` wrapping of large data structures.
+#[derive(Clone, Debug, Default)]
+pub struct MintMetadata {
+    /// Mint server information (name, description, supported features, etc.)
+    pub mint_info: MintInfo,
+
+    /// All keysets indexed by their ID (includes both active and inactive)
+    pub keysets: HashMap<Id, Arc<KeySetInfo>>,
+
+    /// Cryptographic keys for each keyset, indexed by keyset ID
+    pub keys: HashMap<Id, Arc<Keys>>,
+
+    /// Subset of keysets that are currently active (cached for convenience)
+    pub active_keysets: Vec<Arc<KeySetInfo>>,
+
+    /// Freshness tracking for regular (non-auth) mint data
+    status: FreshnessStatus,
+
+    /// Freshness tracking for blind auth keysets (when `auth` feature enabled)
+    #[cfg(feature = "auth")]
+    auth_status: FreshnessStatus,
+}
+
+/// On-demand mint metadata cache with deferred database persistence
+///
+/// Manages a single mint's cryptographic keys and metadata. Fetches data from
+/// the mint's HTTP API on-demand and caches it in memory. Database writes are
+/// deferred to background tasks to avoid blocking operations.
+///
+/// # Thread Safety
+///
+/// All methods are safe to call concurrently. The cache uses `ArcSwap` for
+/// lock-free reads and atomic updates.
+///
+/// # Cloning
+///
+/// Cheap to clone - all data is behind `Arc`. Clones share the same cache.
+#[derive(Clone)]
+pub struct MintMetadataCache {
+    /// The mint server URL this cache manages
+    mint_url: MintUrl,
+
+    /// Atomically-updated metadata snapshot (lock-free reads)
+    metadata: Arc<ArcSwap<MintMetadata>>,
+
+    /// Tracks which database instances have been synced to which cache version.
+    /// Key: pointer identity of storage Arc, Value: last synced cache version
+    db_sync_versions: Arc<RwLock<HashMap<usize, usize>>>,
+}
+
+impl std::fmt::Debug for MintMetadataCache {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("MintMetadataCache")
+            .field("mint_url", &self.mint_url)
+            .field("is_populated", &self.metadata.load().status.is_populated)
+            .field("keyset_count", &self.metadata.load().keysets.len())
+            .finish()
+    }
+}
+
+impl MintMetadataCache {
+    /// Compute a unique identifier for an Arc pointer
+    ///
+    /// Used to track which storage instances have been synced. We use pointer
+    /// identity rather than a counter because wallets may use multiple storage
+    /// backends simultaneously (e.g., different databases for different mints).
+    fn arc_pointer_id<T>(arc: &Arc<T>) -> usize
+    where
+        T: ?Sized,
+    {
+        Arc::as_ptr(arc) as *const () as usize
+    }
+
+    /// Create a new metadata cache for the given mint
+    ///
+    /// This is a cheap operation that only allocates memory. No network or
+    /// database I/O occurs until `load()` or `load_from_mint()` is called.
+    ///
+    /// # Example
+    ///
+    /// ```ignore
+    /// let cache = MintMetadataCache::new(mint_url);
+    /// // No data loaded yet - call load() to fetch
+    /// ```
+    pub fn new(mint_url: MintUrl) -> Self {
+        Self {
+            mint_url,
+            metadata: Arc::new(ArcSwap::default()),
+            db_sync_versions: Arc::new(Default::default()),
+        }
+    }
+
+    /// Load metadata from mint server and update cache
+    ///
+    /// Always performs an HTTP fetch from the mint server to get fresh data.
+    /// Updates the in-memory cache and spawns a background task to persist
+    /// to the database.
+    ///
+    /// Use this when you need guaranteed fresh data from the mint.
+    ///
+    /// # Arguments
+    ///
+    /// * `storage` - Database to persist metadata to (async background write)
+    /// * `client` - HTTP client for fetching from mint server
+    ///
+    /// # Returns
+    ///
+    /// Fresh metadata from the mint server
+    ///
+    /// # Example
+    ///
+    /// ```ignore
+    /// // Force refresh from mint (ignores cache)
+    /// let fresh = cache.load_from_mint(&storage, &client).await?;
+    /// ```
+    #[inline(always)]
+    pub async fn load_from_mint(
+        &self,
+        storage: &Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        client: &Arc<dyn MintConnector + Send + Sync>,
+    ) -> Result<Arc<MintMetadata>, Error> {
+        #[cfg(feature = "auth")]
+        let metadata = self.fetch_from_http(Some(client), None).await?;
+
+        #[cfg(not(feature = "auth"))]
+        let metadata = self.fetch_from_http(Some(client)).await?;
+
+        // Spawn background task to persist to database (non-blocking)
+        self.spawn_database_sync(storage.clone(), metadata.clone());
+
+        Ok(metadata)
+    }
+
+    /// Load metadata from cache or fetch if not available
+    ///
+    /// Returns cached metadata if available, otherwise fetches from the mint.
+    /// If cache is stale relative to the database, spawns a background sync task.
+    ///
+    /// This is the primary method for normal operations - it balances freshness
+    /// with performance by returning cached data when available.
+    ///
+    /// # Arguments
+    ///
+    /// * `storage` - Database to persist metadata to (if fetched or stale)
+    /// * `client` - HTTP client for fetching from mint (only if cache empty)
+    ///
+    /// # Returns
+    ///
+    /// Metadata from cache if available, otherwise fresh from mint
+    ///
+    /// # Example
+    ///
+    /// ```ignore
+    /// // Use cached data if available, fetch if not
+    /// let metadata = cache.load(&storage, &client).await?;
+    /// ```
+    #[inline(always)]
+    pub async fn load(
+        &self,
+        storage: &Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        client: &Arc<dyn MintConnector + Send + Sync>,
+    ) -> Result<Arc<MintMetadata>, Error> {
+        let cached_metadata = self.metadata.load().clone();
+        let storage_id = Self::arc_pointer_id(storage);
+
+        // Check what version of cache this database has seen
+        let db_synced_version = self
+            .db_sync_versions
+            .read()
+            .get(&storage_id)
+            .cloned()
+            .unwrap_or_default();
+
+        if cached_metadata.status.is_populated {
+            // Cache is ready - check if database needs updating
+            if db_synced_version != cached_metadata.status.version {
+                // Database is stale - sync in background
+                // We spawn rather than await to avoid blocking the caller
+                // and to prevent deadlocks with any existing transactions
+                self.spawn_database_sync(storage.clone(), cached_metadata.clone());
+            }
+            return Ok(cached_metadata);
+        }
+
+        // Cache not populated - fetch from mint
+        self.load_from_mint(storage, client).await
+    }
+
+    /// Load auth keysets and keys (auth feature only)
+    ///
+    /// Fetches blind authentication keysets from the mint. Always performs
+    /// an HTTP fetch to get current auth keysets.
+    ///
+    /// # Arguments
+    ///
+    /// * `storage` - Database to persist metadata to
+    /// * `auth_client` - Auth-capable HTTP client for fetching blind auth keysets
+    ///
+    /// # Returns
+    ///
+    /// Metadata containing auth keysets and keys
+    #[cfg(feature = "auth")]
+    pub async fn load_auth(
+        &self,
+        storage: &Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        auth_client: &Arc<dyn AuthMintConnector + Send + Sync>,
+    ) -> Result<Arc<MintMetadata>, Error> {
+        let cached_metadata = self.metadata.load().clone();
+        let storage_id = Self::arc_pointer_id(storage);
+
+        let db_synced_version = self
+            .db_sync_versions
+            .read()
+            .get(&storage_id)
+            .cloned()
+            .unwrap_or_default();
+
+        // Check if auth data is populated in cache
+        if cached_metadata.auth_status.is_populated {
+            if db_synced_version != cached_metadata.status.version {
+                // Database needs updating - spawn background sync
+                self.spawn_database_sync(storage.clone(), cached_metadata.clone());
+            }
+            return Ok(cached_metadata);
+        }
+
+        // Auth data not in cache - fetch from mint
+        let metadata = self.fetch_from_http(None, Some(auth_client)).await?;
+
+        // Spawn background task to persist
+        self.spawn_database_sync(storage.clone(), metadata.clone());
+
+        Ok(metadata)
+    }
+
+    /// Spawn a background task to sync metadata to database
+    ///
+    /// This is non-blocking and happens asynchronously. The task will:
+    /// 1. Check if this sync is still needed (version may be superseded)
+    /// 2. Save mint info, keysets, and keys to the database
+    /// 3. Update the sync tracking to record this storage has been updated
+    fn spawn_database_sync(
+        &self,
+        storage: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        metadata: Arc<MintMetadata>,
+    ) {
+        let mint_url = self.mint_url.clone();
+        let db_sync_versions = self.db_sync_versions.clone();
+
+        spawn(async move {
+            Self::persist_to_database(mint_url, storage, metadata, db_sync_versions).await
+        });
+    }
+
+    /// Persist metadata to database (called from background task)
+    ///
+    /// Saves mint info, keysets, and keys to the database. Checks version
+    /// before writing to avoid redundant work if a newer version has already
+    /// been persisted.
+    ///
+    /// # Arguments
+    ///
+    /// * `mint_url` - Mint URL for database keys
+    /// * `storage` - Database to write to
+    /// * `metadata` - Metadata to persist
+    /// * `db_sync_versions` - Shared version tracker
+    async fn persist_to_database(
+        mint_url: MintUrl,
+        storage: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        metadata: Arc<MintMetadata>,
+        db_sync_versions: Arc<RwLock<HashMap<usize, usize>>>,
+    ) {
+        let storage_id = Self::arc_pointer_id(&storage);
+
+        // Check if this write is still needed
+        {
+            let mut versions = db_sync_versions.write();
+
+            let current_synced_version = versions.get(&storage_id).cloned().unwrap_or_default();
+
+            if metadata.status.version <= current_synced_version {
+                // A newer version has already been persisted - skip this write
+                return;
+            }
+
+            // Mark this version as being synced
+            versions.insert(storage_id, metadata.status.version);
+        }
+
+        // Save mint info
+        storage
+            .add_mint(mint_url.clone(), Some(metadata.mint_info.clone()))
+            .await
+            .inspect_err(|e| tracing::warn!("Failed to save mint info for {}: {}", mint_url, e))
+            .ok();
+
+        // Save all keysets
+        let keysets: Vec<_> = metadata.keysets.values().map(|ks| (**ks).clone()).collect();
+
+        if !keysets.is_empty() {
+            storage
+                .add_mint_keysets(mint_url.clone(), keysets)
+                .await
+                .inspect_err(|e| tracing::warn!("Failed to save keysets for {}: {}", mint_url, e))
+                .ok();
+        }
+
+        // Save keys for each keyset
+        for (keyset_id, keys) in &metadata.keys {
+            if let Some(keyset_info) = metadata.keysets.get(keyset_id) {
+                let keyset = KeySet {
+                    id: *keyset_id,
+                    unit: keyset_info.unit.clone(),
+                    final_expiry: keyset_info.final_expiry,
+                    keys: (**keys).clone(),
+                };
+
+                storage
+                    .add_keys(keyset)
+                    .await
+                    .inspect_err(|e| {
+                        tracing::warn!(
+                            "Failed to save keys for keyset {} at {}: {}",
+                            keyset_id,
+                            mint_url,
+                            e
+                        )
+                    })
+                    .ok();
+            }
+        }
+    }
+
+    /// Fetch fresh metadata from mint HTTP API and update cache
+    ///
+    /// Performs the following steps:
+    /// 1. Fetches mint info from server
+    /// 2. Fetches list of all keysets
+    /// 3. Fetches cryptographic keys for each keyset
+    /// 4. Verifies keyset IDs match their keys
+    /// 5. Atomically updates in-memory cache
+    ///
+    /// # Arguments
+    ///
+    /// * `client` - Optional regular mint client (for non-auth operations)
+    /// * `auth_client` - Optional auth client (for blind auth keysets)
+    ///
+    /// # Returns
+    ///
+    /// Newly fetched and cached metadata
+    async fn fetch_from_http(
+        &self,
+        client: Option<&Arc<dyn MintConnector + Send + Sync>>,
+        #[cfg(feature = "auth")] auth_client: Option<&Arc<dyn AuthMintConnector + Send + Sync>>,
+    ) -> Result<Arc<MintMetadata>, Error> {
+        tracing::debug!("Fetching mint metadata from HTTP for {}", self.mint_url);
+
+        // Start with current cache to preserve data from other sources
+        let mut new_metadata = (*self.metadata.load().clone()).clone();
+        let mut keysets_to_fetch = Vec::new();
+
+        // Fetch regular mint data
+        if let Some(client) = client.as_ref() {
+            // Get mint information
+            new_metadata.mint_info = client.get_mint_info().await.inspect_err(|err| {
+                tracing::error!("Failed to fetch mint info for {}: {}", self.mint_url, err);
+            })?;
+
+            // Get list of keysets
+            keysets_to_fetch.extend(
+                client
+                    .get_mint_keysets()
+                    .await
+                    .inspect_err(|err| {
+                        tracing::error!("Failed to fetch keysets for {}: {}", self.mint_url, err);
+                    })?
+                    .keysets,
+            );
+        }
+
+        // Fetch auth keysets if auth client provided
+        #[cfg(feature = "auth")]
+        if let Some(auth_client) = auth_client.as_ref() {
+            keysets_to_fetch.extend(auth_client.get_mint_blind_auth_keysets().await?.keysets);
+        }
+
+        tracing::debug!(
+            "Fetched {} keysets for {}",
+            keysets_to_fetch.len(),
+            self.mint_url
+        );
+
+        // Fetch keys for each keyset
+        for keyset_info in keysets_to_fetch {
+            let keyset_arc = Arc::new(keyset_info.clone());
+            new_metadata
+                .keysets
+                .insert(keyset_info.id, keyset_arc.clone());
+
+            // Track active keysets separately for quick access
+            if keyset_info.active {
+                new_metadata.active_keysets.push(keyset_arc);
+            }
+
+            // Only fetch keys if we don't already have them cached
+            if let std::collections::hash_map::Entry::Vacant(e) =
+                new_metadata.keys.entry(keyset_info.id)
+            {
+                let keyset = if let Some(client) = client.as_ref() {
+                    client.get_mint_keyset(keyset_info.id).await?
+                } else {
+                    #[cfg(feature = "auth")]
+                    if let Some(auth_client) = auth_client.as_ref() {
+                        auth_client
+                            .get_mint_blind_auth_keyset(keyset_info.id)
+                            .await?
+                    } else {
+                        return Err(Error::Internal);
+                    }
+
+                    #[cfg(not(feature = "auth"))]
+                    return Err(Error::Internal);
+                };
+
+                // Verify the keyset ID matches the keys
+                keyset.verify_id()?;
+
+                e.insert(Arc::new(keyset.keys));
+            }
+        }
+
+        // Update freshness status based on what was fetched
+        if client.is_some() {
+            new_metadata.status.is_populated = true;
+            new_metadata.status.last_fetched_at = Instant::now();
+            new_metadata.status.version += 1;
+        }
+
+        #[cfg(feature = "auth")]
+        if auth_client.is_some() {
+            new_metadata.auth_status.is_populated = true;
+            new_metadata.auth_status.last_fetched_at = Instant::now();
+            new_metadata.auth_status.version += 1;
+        }
+
+        tracing::info!(
+            "Updated cache for {} with {} keysets (version {})",
+            self.mint_url,
+            new_metadata.keysets.len(),
+            new_metadata.status.version
+        );
+
+        // Atomically update cache
+        let metadata_arc = Arc::new(new_metadata);
+        self.metadata.store(metadata_arc.clone());
+        Ok(metadata_arc)
+    }
+
+    /// Get the mint URL this cache manages
+    pub fn mint_url(&self) -> &MintUrl {
+        &self.mint_url
+    }
+}

+ 18 - 7
crates/cdk/src/wallet/mod.rs

@@ -28,6 +28,7 @@ use crate::nuts::{
 };
 use crate::types::ProofInfo;
 use crate::util::unix_time;
+use crate::wallet::mint_metadata_cache::MintMetadataCache;
 use crate::Amount;
 #[cfg(feature = "auth")]
 use crate::OidcClient;
@@ -39,10 +40,10 @@ pub use mint_connector::TorHttpClient;
 mod balance;
 mod builder;
 mod issue;
-mod key_manager;
 mod keysets;
 mod melt;
 mod mint_connector;
+mod mint_metadata_cache;
 pub mod multi_mint_wallet;
 pub mod payment_request;
 mod proofs;
@@ -87,8 +88,8 @@ pub struct Wallet {
     pub unit: CurrencyUnit,
     /// Storage backend
     pub localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
-    /// Key manager for this mint (lock-free cached key access)
-    pub key_manager: Arc<key_manager::KeyManager>,
+    /// Mint metadata cache for this mint (lock-free cached access to keys, keysets, and mint info)
+    pub metadata_cache: Arc<MintMetadataCache>,
     /// The targeted amount of proofs to have at each size
     pub target_proof_count: usize,
     #[cfg(feature = "auth")]
@@ -220,9 +221,16 @@ impl Wallet {
         proofs_per_keyset: HashMap<Id, u64>,
     ) -> Result<Amount, Error> {
         let mut fee_per_keyset = HashMap::new();
+        let metadata = self
+            .metadata_cache
+            .load(&self.localstore, &self.client)
+            .await?;
 
         for keyset_id in proofs_per_keyset.keys() {
-            let mint_keyset_info = self.key_manager.get_keyset_by_id(keyset_id).await?;
+            let mint_keyset_info = metadata
+                .keysets
+                .get(keyset_id)
+                .ok_or(Error::UnknownKeySet)?;
             fee_per_keyset.insert(*keyset_id, mint_keyset_info.input_fee_ppk);
         }
 
@@ -235,9 +243,12 @@ impl Wallet {
     #[instrument(skip_all)]
     pub async fn get_keyset_count_fee(&self, keyset_id: &Id, count: u64) -> Result<Amount, Error> {
         let input_fee_ppk = self
-            .key_manager
-            .get_keyset_by_id(keyset_id)
+            .metadata_cache
+            .load(&self.localstore, &self.client)
             .await?
+            .keysets
+            .get(keyset_id)
+            .ok_or(Error::UnknownKeySet)?
             .input_fee_ppk;
 
         let fee = (input_fee_ppk * count).div_ceil(1000);
@@ -305,7 +316,7 @@ impl Wallet {
                                 self.mint_url.clone(),
                                 None,
                                 self.localstore.clone(),
-                                self.key_manager.clone(),
+                                self.metadata_cache.clone(),
                                 mint_info.protected_endpoints(),
                                 oidc_client,
                             );

+ 8 - 1
crates/cdk/src/wallet/swap.rs

@@ -49,7 +49,14 @@ impl Wallet {
             .get_keyset_fees_and_amounts_by_id(active_keyset_id)
             .await?;
 
-        let active_keys = self.key_manager.get_keys(&active_keyset_id).await?;
+        let active_keys = self
+            .metadata_cache
+            .load(&self.localstore, &self.client)
+            .await?
+            .keys
+            .get(&active_keyset_id)
+            .ok_or(Error::UnknownKeySet)?
+            .clone();
 
         let post_swap_proofs = construct_proofs(
             swap_response.signatures,