Pārlūkot izejas kodu

Introduce MintMetadataCache for efficient key and metadata management (#1240)

This commit introduces a new MintMetadataCache subsystem that centralizes mint metadata (keys, keysets, and mint info) management with in-memory caching, deferred database persistence, and lock-free read access. This architectural change eliminates redundant database queries, reduces mint server load, and improves wallet performance.

  Previous Architecture

  Each wallet independently queried the database and mint server for keys:
  - Duplicate HTTP requests to mint servers
  - No coordination between wallets sharing the same mint
  - Blocking I/O on every key access

  New Architecture

  MintMetadataCache provides per-mint caching with:
  - Single source of truth for all mint metadata
  - Lock-free reads via ArcSwap (zero contention)
  - Deferred database writes (non-blocking)
  - Mutex-based fetch coordination (prevents duplicate HTTP requests)
  - Multi-database support (tracks sync status per storage instance)

  Key Design Decisions

  Pull-based loading instead of background workers:
  - load() - Returns cached data if available, fetches if not
  - load_from_mint() - Always fetches fresh data from mint server
  - load_auth() - Fetches blind auth keysets (when auth feature enabled)

  Deferred persistence:
  - Database writes spawn in background after cache update
  - Cache is source of truth, not database
  - Tracks which databases have been synced via pointer identity

  Fetch coordination:
  - Mutex ensures only one HTTP fetch runs at a time
  - Waiting threads check if cache was updated while waiting
  - Avoids redundant fetches when multiple callers request simultaneously

  Implementation Details

  - 327 lines added to new mint_metadata_cache.rs module
  - Updated Wallet methods to use new cache API
  - Removed synchronous polling patterns in favor of explicit load calls
  - Added per-wallet TTL configuration via set_metadata_cache_ttl()

  Benefits

  - Eliminates duplicate HTTP requests across wallets
  - Reduces database query load
  - Improves response time through in-memory caching
  - Better resource utilization (memory-efficient shared state)
  - Clearer separation between cache-first (load) and fetch-first (load_from_mint) semantics
C 6 dienas atpakaļ
vecāks
revīzija
32c9288940

+ 3 - 0
crates/cdk-common/src/lib.rs

@@ -8,6 +8,8 @@
 #![warn(missing_docs)]
 #![warn(rustdoc::bare_urls)]
 
+pub mod task;
+
 pub mod common;
 pub mod database;
 pub mod error;
@@ -24,6 +26,7 @@ pub mod subscription;
 #[cfg(feature = "wallet")]
 pub mod wallet;
 pub mod ws;
+
 // re-exporting external crates
 pub use bitcoin;
 pub use cashu::amount::{self, Amount};

+ 3 - 3
crates/cdk-common/src/mint.rs

@@ -49,7 +49,7 @@ impl FromStr for OperationKind {
             "swap" => Ok(OperationKind::Swap),
             "mint" => Ok(OperationKind::Mint),
             "melt" => Ok(OperationKind::Melt),
-            _ => Err(Error::Custom(format!("Invalid operation kind: {}", value))),
+            _ => Err(Error::Custom(format!("Invalid operation kind: {value}"))),
         }
     }
 }
@@ -80,7 +80,7 @@ impl FromStr for SwapSagaState {
         match value.as_str() {
             "setup_complete" => Ok(SwapSagaState::SetupComplete),
             "signed" => Ok(SwapSagaState::Signed),
-            _ => Err(Error::Custom(format!("Invalid swap saga state: {}", value))),
+            _ => Err(Error::Custom(format!("Invalid swap saga state: {value}"))),
         }
     }
 }
@@ -279,7 +279,7 @@ impl Operation {
             "mint" => Ok(Self::Mint(uuid)),
             "melt" => Ok(Self::Melt(uuid)),
             "swap" => Ok(Self::Swap(uuid)),
-            _ => Err(Error::Custom(format!("Invalid operation kind: {}", kind))),
+            _ => Err(Error::Custom(format!("Invalid operation kind: {kind}"))),
         }
     }
 }

+ 3 - 14
crates/cdk-common/src/pub_sub/pubsub.rs

@@ -10,6 +10,7 @@ use tokio::sync::mpsc;
 
 use super::subscriber::{ActiveSubscription, SubscriptionRequest};
 use super::{Error, Event, Spec, Subscriber};
+use crate::task::spawn;
 
 /// Default channel size for subscription buffering
 pub const DEFAULT_CHANNEL_SIZE: usize = 10_000;
@@ -92,13 +93,7 @@ where
         let topics = self.listeners_topics.clone();
         let event = event.into();
 
-        #[cfg(not(target_arch = "wasm32"))]
-        tokio::spawn(async move {
-            let _ = Self::publish_internal(event, &topics);
-        });
-
-        #[cfg(target_arch = "wasm32")]
-        wasm_bindgen_futures::spawn_local(async move {
+        spawn(async move {
             let _ = Self::publish_internal(event, &topics);
         });
     }
@@ -150,17 +145,11 @@ where
         let inner = self.inner.clone();
         let subscribed_to_for_spawn = subscribed_to.clone();
 
-        #[cfg(not(target_arch = "wasm32"))]
-        tokio::spawn(async move {
+        spawn(async move {
             // TODO: Ignore topics broadcasted from fetch_events _if_ any real time has been broadcasted already.
             inner.fetch_events(subscribed_to_for_spawn, sender).await;
         });
 
-        #[cfg(target_arch = "wasm32")]
-        wasm_bindgen_futures::spawn_local(async move {
-            inner.fetch_events(subscribed_to_for_spawn, sender).await;
-        });
-
         Ok(ActiveSubscription::new(
             subscription_internal_id,
             subscription_name,

+ 2 - 8
crates/cdk-common/src/pub_sub/remote_consumer.rs

@@ -12,6 +12,7 @@ use tokio::time::{sleep, Instant};
 
 use super::subscriber::{ActiveSubscription, SubscriptionRequest};
 use super::{Error, Event, Pubsub, Spec};
+use crate::task::spawn;
 
 const STREAM_CONNECTION_BACKOFF: Duration = Duration::from_millis(2_000);
 
@@ -21,9 +22,6 @@ const INTERNAL_POLL_SIZE: usize = 1_000;
 
 const POLL_SLEEP: Duration = Duration::from_millis(2_000);
 
-#[cfg(target_arch = "wasm32")]
-use wasm_bindgen_futures;
-
 struct UniqueSubscription<S>
 where
     S: Spec,
@@ -157,11 +155,7 @@ where
             still_running: true.into(),
         });
 
-        #[cfg(not(target_arch = "wasm32"))]
-        tokio::spawn(Self::stream(this.clone()));
-
-        #[cfg(target_arch = "wasm32")]
-        wasm_bindgen_futures::spawn_local(Self::stream(this.clone()));
+        spawn(Self::stream(this.clone()));
 
         this
     }

+ 25 - 0
crates/cdk-common/src/task.rs

@@ -0,0 +1,25 @@
+//! Thin wrapper for spawn and spawn_local for native and wasm.
+
+use std::future::Future;
+
+use tokio::task::JoinHandle;
+
+/// Spawns a new asynchronous task returning nothing
+#[cfg(not(target_arch = "wasm32"))]
+pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
+where
+    F: Future + Send + 'static,
+    F::Output: Send + 'static,
+{
+    tokio::spawn(future)
+}
+
+/// Spawns a new asynchronous task returning nothing
+#[cfg(target_arch = "wasm32")]
+pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
+where
+    F: Future + 'static,
+    F::Output: 'static,
+{
+    tokio::task::spawn_local(future)
+}

+ 2 - 2
crates/cdk-redb/src/wallet/mod.rs

@@ -63,7 +63,7 @@ impl WalletRedbDatabase {
                 if !parent.exists() {
                     return Err(Error::Io(std::io::Error::new(
                         std::io::ErrorKind::NotFound,
-                        format!("Parent directory does not exist: {:?}", parent),
+                        format!("Parent directory does not exist: {parent:?}"),
                     )));
                 }
             }
@@ -171,7 +171,7 @@ impl WalletRedbDatabase {
             if !parent.exists() {
                 return Err(Error::Io(std::io::Error::new(
                     std::io::ErrorKind::NotFound,
-                    format!("Parent directory does not exist: {:?}", parent),
+                    format!("Parent directory does not exist: {parent:?}"),
                 )));
             }
         }

+ 2 - 3
crates/cdk-sql-common/build.rs

@@ -23,7 +23,7 @@ fn main() {
             .unwrap_or("default")
             .replace("/", "_")
             .replace("\\", "_");
-        let dest_path = out_dir.join(format!("migrations_{}.rs", migration_name));
+        let dest_path = out_dir.join(format!("migrations_{migration_name}.rs"));
         let mut out_file = File::create(&dest_path).expect("Failed to create migrations.rs");
 
         let skip_name = migration_path.to_str().unwrap_or_default().len();
@@ -115,8 +115,7 @@ fn main() {
             let relative_to_out_dir = relative_path.to_str().unwrap().replace("\\", "/");
             writeln!(
                 out_file,
-                "    (\"{prefix}\", \"{rel_name}\", include_str!(r#\"{}\"#)),",
-                relative_to_out_dir
+                "    (\"{prefix}\", \"{rel_name}\", include_str!(r#\"{relative_to_out_dir}\"#)),"
             )
             .unwrap();
             println!("cargo:rerun-if-changed={}", path.display());

+ 7 - 7
crates/cdk-sql-common/src/mint/mod.rs

@@ -2248,10 +2248,10 @@ where
         let current_time = unix_time();
 
         let blinded_secrets_json = serde_json::to_string(&saga.blinded_secrets)
-            .map_err(|e| Error::Internal(format!("Failed to serialize blinded_secrets: {}", e)))?;
+            .map_err(|e| Error::Internal(format!("Failed to serialize blinded_secrets: {e}")))?;
 
         let input_ys_json = serde_json::to_string(&saga.input_ys)
-            .map_err(|e| Error::Internal(format!("Failed to serialize input_ys: {}", e)))?;
+            .map_err(|e| Error::Internal(format!("Failed to serialize input_ys: {e}")))?;
 
         query(
             r#"
@@ -2645,23 +2645,23 @@ fn sql_row_to_saga(row: Vec<Column>) -> Result<mint::Saga, Error> {
 
     let operation_id_str = column_as_string!(&operation_id);
     let operation_id = uuid::Uuid::parse_str(&operation_id_str)
-        .map_err(|e| Error::Internal(format!("Invalid operation_id UUID: {}", e)))?;
+        .map_err(|e| Error::Internal(format!("Invalid operation_id UUID: {e}")))?;
 
     let operation_kind_str = column_as_string!(&operation_kind);
     let operation_kind = mint::OperationKind::from_str(&operation_kind_str)
-        .map_err(|e| Error::Internal(format!("Invalid operation kind: {}", e)))?;
+        .map_err(|e| Error::Internal(format!("Invalid operation kind: {e}")))?;
 
     let state_str = column_as_string!(&state);
     let state = mint::SagaStateEnum::new(operation_kind, &state_str)
-        .map_err(|e| Error::Internal(format!("Invalid saga state: {}", e)))?;
+        .map_err(|e| Error::Internal(format!("Invalid saga state: {e}")))?;
 
     let blinded_secrets_str = column_as_string!(&blinded_secrets);
     let blinded_secrets: Vec<PublicKey> = serde_json::from_str(&blinded_secrets_str)
-        .map_err(|e| Error::Internal(format!("Failed to deserialize blinded_secrets: {}", e)))?;
+        .map_err(|e| Error::Internal(format!("Failed to deserialize blinded_secrets: {e}")))?;
 
     let input_ys_str = column_as_string!(&input_ys);
     let input_ys: Vec<PublicKey> = serde_json::from_str(&input_ys_str)
-        .map_err(|e| Error::Internal(format!("Failed to deserialize input_ys: {}", e)))?;
+        .map_err(|e| Error::Internal(format!("Failed to deserialize input_ys: {e}")))?;
 
     let quote_id = match &quote_id {
         Column::Text(s) => {

+ 12 - 0
crates/cdk-sql-common/src/stmt.rs

@@ -104,6 +104,18 @@ pub fn split_sql_parts(input: &str) -> Result<Vec<SqlPart>, SqlParseError> {
                 }
             }
 
+            '-' => {
+                current.push(chars.next().unwrap());
+                if chars.peek() == Some(&'-') {
+                    while let Some(&next) = chars.peek() {
+                        current.push(chars.next().unwrap());
+                        if next == '\n' {
+                            break;
+                        }
+                    }
+                }
+            }
+
             ':' => {
                 // Flush current raw SQL
                 if !current.is_empty() {

+ 15 - 0
crates/cdk-sql-common/src/wallet/migrations/postgres/20251111000000_keyset_counter_table.sql

@@ -0,0 +1,15 @@
+-- Create dedicated keyset_counter table without foreign keys
+-- This table tracks the counter for each keyset independently
+CREATE TABLE IF NOT EXISTS keyset_counter (
+    keyset_id TEXT PRIMARY KEY,
+    counter INTEGER NOT NULL DEFAULT 0
+);
+
+-- Migrate existing counter values from keyset table
+INSERT INTO keyset_counter (keyset_id, counter)
+SELECT id, counter
+FROM keyset
+WHERE counter > 0;
+
+-- Drop the counter column from keyset table
+ALTER TABLE keyset DROP COLUMN counter;

+ 36 - 0
crates/cdk-sql-common/src/wallet/migrations/sqlite/20251111000000_keyset_counter_table.sql

@@ -0,0 +1,36 @@
+-- Create dedicated keyset_counter table without foreign keys
+-- This table tracks the counter for each keyset independently
+CREATE TABLE IF NOT EXISTS keyset_counter (
+    keyset_id TEXT PRIMARY KEY,
+    counter INTEGER NOT NULL DEFAULT 0
+);
+
+-- Migrate existing counter values from keyset table
+INSERT INTO keyset_counter (keyset_id, counter)
+SELECT id, counter
+FROM keyset
+WHERE counter > 0;
+
+-- Drop the counter column from keyset table (SQLite requires table recreation)
+-- Step 1: Create new keyset table without counter column
+CREATE TABLE keyset_new (
+    id TEXT PRIMARY KEY,
+    mint_url TEXT NOT NULL,
+    keyset_u32 INTEGER,
+    unit TEXT NOT NULL,
+    active BOOL NOT NULL,
+    input_fee_ppk INTEGER,
+    final_expiry INTEGER DEFAULT NULL,
+    FOREIGN KEY(mint_url) REFERENCES mint(mint_url) ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+-- Step 2: Copy data from old keyset table (excluding counter)
+INSERT INTO keyset_new (id, keyset_u32, mint_url, unit, active, input_fee_ppk, final_expiry)
+SELECT id, keyset_u32, mint_url, unit, active, input_fee_ppk, final_expiry
+FROM keyset;
+
+-- Step 3: Drop old keyset table
+DROP TABLE keyset;
+
+-- Step 4: Rename new table to keyset
+ALTER TABLE keyset_new RENAME TO keyset;

+ 10 - 9
crates/cdk-sql-common/src/wallet/mod.rs

@@ -917,16 +917,16 @@ ON CONFLICT(id) DO UPDATE SET
         let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
         let tx = ConnectionWithTransaction::new(conn).await?;
 
-        // Lock the row and get current counter
+        // Lock the row and get current counter from keyset_counter table
         let current_counter = query(
             r#"
             SELECT counter
-            FROM keyset
-            WHERE id=:id
+            FROM keyset_counter
+            WHERE keyset_id=:keyset_id
             FOR UPDATE
             "#,
         )?
-        .bind("id", keyset_id.to_string())
+        .bind("keyset_id", keyset_id.to_string())
         .pluck(&tx)
         .await?
         .map(|n| Ok::<_, Error>(column_as_number!(n)))
@@ -935,16 +935,17 @@ ON CONFLICT(id) DO UPDATE SET
 
         let new_counter = current_counter + count;
 
-        // Update with the new counter value
+        // Upsert the new counter value
         query(
             r#"
-            UPDATE keyset
-            SET counter=:new_counter
-            WHERE id=:id
+            INSERT INTO keyset_counter (keyset_id, counter)
+            VALUES (:keyset_id, :new_counter)
+            ON CONFLICT(keyset_id) DO UPDATE SET
+                counter = excluded.counter
             "#,
         )?
+        .bind("keyset_id", keyset_id.to_string())
         .bind("new_counter", new_counter)
-        .bind("id", keyset_id.to_string())
         .execute(&tx)
         .await?;
 

+ 1 - 1
crates/cdk/examples/proof-selection.rs

@@ -52,7 +52,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     // Select proofs to send
     let amount = Amount::from(64);
     let active_keyset_ids = wallet
-        .refresh_keysets()
+        .get_mint_keysets()
         .await?
         .active()
         .map(|keyset| keyset.id)

+ 62 - 74
crates/cdk/src/wallet/auth/auth_wallet.rs

@@ -3,7 +3,6 @@ use std::sync::Arc;
 
 use cdk_common::database::{self, WalletDatabase};
 use cdk_common::mint_url::MintUrl;
-use cdk_common::nut02::KeySetInfosMethods;
 use cdk_common::{AuthProof, Id, Keys, MintInfo};
 use serde::{Deserialize, Serialize};
 use tokio::sync::RwLock;
@@ -19,6 +18,7 @@ use crate::nuts::{
 };
 use crate::types::ProofInfo;
 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,11 +40,13 @@ pub struct AuthWallet {
     pub mint_url: MintUrl,
     /// Storage backend
     pub localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+    /// 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>>>,
 }
@@ -55,6 +57,7 @@ impl AuthWallet {
         mint_url: MintUrl,
         cat: Option<AuthToken>,
         localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        metadata_cache: Arc<MintMetadataCache>,
         protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
         oidc_client: Option<OidcClient>,
     ) -> Self {
@@ -62,9 +65,10 @@ impl AuthWallet {
         Self {
             mint_url,
             localstore,
+            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)),
         }
     }
@@ -72,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
@@ -99,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(),
@@ -165,94 +169,78 @@ 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 local database if they are already stored.
-    /// If keys are not found locally, goes online to query the mint for the keyset and stores the [`Keys`] in local database.
+    /// 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> {
-        let keys = if let Some(keys) = self.localstore.get_keys(&keyset_id).await? {
-            keys
-        } else {
-            let keys = self.client.get_mint_blind_auth_keyset(keyset_id).await?;
-
-            keys.verify_id()?;
-
-            self.localstore.add_keys(keys.clone()).await?;
-
-            keys.keys
-        };
-
-        Ok(keys)
+        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 local database or go online if missing
+    /// Get blind auth keysets from metadata cache
     ///
-    /// First checks the local database for cached blind auth keysets. If keysets are not found locally,
-    /// goes online to refresh keysets from the mint and updates the local database.
+    /// 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> {
-        match self
-            .localstore
-            .get_mint_keysets(self.mint_url.clone())
-            .await?
-        {
-            Some(keysets_info) => {
-                let auth_keysets: Vec<KeySetInfo> =
-                    keysets_info.unit(CurrencyUnit::Sat).cloned().collect();
-                if auth_keysets.is_empty() {
-                    // If we don't have any auth keysets, fetch them from the mint
-                    let keysets = self.refresh_keysets().await?;
-                    Ok(keysets)
+        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 {
-                    Ok(auth_keysets)
+                    None
                 }
-            }
-            None => {
-                // If we don't have any keysets, fetch them from the mint
-                let keysets = self.refresh_keysets().await?;
-                Ok(keysets)
-            }
+            })
+            .collect::<Vec<_>>();
+
+        if !auth_keysets.is_empty() {
+            Ok(auth_keysets)
+        } else {
+            Err(Error::UnknownKeySet)
         }
     }
 
-    /// Refresh blind auth keysets by fetching the latest from mint - always goes online
+    /// Refresh blind auth keysets by fetching the latest from mint
     ///
-    /// This method always goes online to fetch the latest blind auth keyset information from the mint.
-    /// It updates the local database with the fetched keysets and ensures we have keys for all keysets.
-    /// Returns only the keysets with Auth currency unit. This is used when operations need the most
-    /// up-to-date keyset information and are willing to go online.
+    /// 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> {
-        let keysets_response = self.client.get_mint_blind_auth_keysets().await?;
-        let keysets = keysets_response.keysets;
-
-        // Update local store with keysets
-        self.localstore
-            .add_mint_keysets(self.mint_url.clone(), keysets.clone())
-            .await?;
-
-        // Filter for auth keysets
-        let auth_keysets = keysets
-            .clone()
-            .into_iter()
-            .filter(|k| k.unit == CurrencyUnit::Auth)
-            .collect::<Vec<KeySetInfo>>();
-
-        // Ensure we have keys for all auth keysets
-        for keyset in &auth_keysets {
-            if self.localstore.get_keys(&keyset.id).await?.is_none() {
-                tracing::debug!("Fetching missing keys for auth keyset {}", keyset.id);
-                self.load_keyset_keys(keyset.id).await?;
-            }
-        }
+        tracing::debug!("Refreshing auth keysets from mint");
 
-        Ok(auth_keysets)
+        self.load_mint_keysets().await
     }
 
     /// Get the first active blind auth keyset - always goes online
@@ -325,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.");
@@ -359,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) => {
@@ -425,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?;
 

+ 67 - 4
crates/cdk/src/wallet/builder.rs

@@ -1,8 +1,9 @@
-#[cfg(feature = "auth")]
 use std::collections::HashMap;
 use std::sync::Arc;
+use std::time::Duration;
 
 use cdk_common::database;
+use cdk_common::parking_lot::Mutex;
 #[cfg(feature = "auth")]
 use cdk_common::AuthToken;
 #[cfg(feature = "auth")]
@@ -14,10 +15,10 @@ use crate::mint_url::MintUrl;
 use crate::nuts::CurrencyUnit;
 #[cfg(feature = "auth")]
 use crate::wallet::auth::AuthWallet;
+use crate::wallet::mint_metadata_cache::MintMetadataCache;
 use crate::wallet::{HttpClient, MintConnector, SubscriptionManager, Wallet};
 
 /// Builder for creating a new [`Wallet`]
-#[derive(Debug)]
 pub struct WalletBuilder {
     mint_url: Option<MintUrl>,
     unit: Option<CurrencyUnit>,
@@ -28,6 +29,9 @@ pub struct WalletBuilder {
     seed: Option<[u8; 64]>,
     use_http_subscription: bool,
     client: Option<Arc<dyn MintConnector + Send + Sync>>,
+    metadata_cache_ttl: Option<Duration>,
+    metadata_cache: Option<Arc<MintMetadataCache>>,
+    metadata_caches: HashMap<MintUrl, Arc<MintMetadataCache>>,
 }
 
 impl Default for WalletBuilder {
@@ -41,7 +45,10 @@ impl Default for WalletBuilder {
             auth_wallet: None,
             seed: None,
             client: None,
+            metadata_cache_ttl: None,
             use_http_subscription: false,
+            metadata_cache: None,
+            metadata_caches: HashMap::new(),
         }
     }
 }
@@ -58,6 +65,12 @@ impl WalletBuilder {
         self
     }
 
+    /// Set metadata_cache_ttl
+    pub fn set_metadata_cache_ttl(mut self, metadata_cache_ttl: Option<Duration>) -> Self {
+        self.metadata_cache_ttl = metadata_cache_ttl;
+        self
+    }
+
     /// If WS is preferred (with fallback to HTTP is it is not supported by the mint) for the wallet
     /// subscriptions to mint events
     pub fn prefer_ws_subscription(mut self) -> Self {
@@ -117,13 +130,49 @@ impl WalletBuilder {
         self
     }
 
+    /// Set a shared MintMetadataCache
+    ///
+    /// 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 metadata_cache(mut self, metadata_cache: Arc<MintMetadataCache>) -> Self {
+        self.metadata_cache = Some(metadata_cache);
+        self
+    }
+
+    /// Set a HashMap of MintMetadataCaches for reusing across multiple wallets
+    ///
+    /// 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
+    }
+
     /// Set auth CAT (Clear Auth Token)
     #[cfg(feature = "auth")]
     pub fn set_auth_cat(mut self, cat: String) -> Self {
+        let mint_url = self.mint_url.clone().expect("Mint URL required");
+        let localstore = self.localstore.clone().expect("Localstore required");
+
+        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(MintMetadataCache::new(mint_url.clone()))
+            }
+        });
+
         self.auth_wallet = Some(AuthWallet::new(
-            self.mint_url.clone().expect("Mint URL required"),
+            mint_url,
             Some(AuthToken::ClearAuth(cat)),
-            self.localstore.clone().expect("Localstore required"),
+            localstore,
+            metadata_cache,
             HashMap::new(),
             None,
         ));
@@ -162,10 +211,24 @@ impl WalletBuilder {
             }
         };
 
+        let metadata_cache_ttl = self.metadata_cache_ttl;
+
+        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(MintMetadataCache::new(mint_url.clone()))
+            }
+        });
+
         Ok(Wallet {
             mint_url,
             unit,
             localstore,
+            metadata_cache,
+            metadata_cache_ttl: Arc::new(Mutex::new(metadata_cache_ttl)),
             target_proof_count: self.target_proof_count.unwrap_or(3),
             #[cfg(feature = "auth")]
             auth_wallet: Arc::new(RwLock::new(self.auth_wallet)),

+ 0 - 4
crates/cdk/src/wallet/issue/issue_bolt11.rs

@@ -52,8 +52,6 @@ impl Wallet {
         let mint_url = self.mint_url.clone();
         let unit = self.unit.clone();
 
-        self.refresh_keysets().await?;
-
         // If we have a description, we check that the mint supports it.
         if description.is_some() {
             let settings = self
@@ -196,8 +194,6 @@ impl Wallet {
         amount_split_target: SplitTarget,
         spending_conditions: Option<SpendingConditions>,
     ) -> Result<Proofs, Error> {
-        self.refresh_keysets().await?;
-
         let quote_info = self
             .localstore
             .get_mint_quote(quote_id)

+ 0 - 4
crates/cdk/src/wallet/issue/issue_bolt12.rs

@@ -29,8 +29,6 @@ impl Wallet {
         let mint_url = self.mint_url.clone();
         let unit = &self.unit;
 
-        self.refresh_keysets().await?;
-
         // If we have a description, we check that the mint supports it.
         if description.is_some() {
             let mint_method_settings = self
@@ -85,8 +83,6 @@ impl Wallet {
         amount_split_target: SplitTarget,
         spending_conditions: Option<SpendingConditions>,
     ) -> Result<Proofs, Error> {
-        self.refresh_keysets().await?;
-
         let quote_info = self.localstore.get_mint_quote(quote_id).await?;
 
         let quote_info = if let Some(quote) = quote_info {

+ 99 - 108
crates/cdk/src/wallet/keysets.rs

@@ -10,109 +10,102 @@ use crate::{Error, Wallet};
 impl Wallet {
     /// Load keys for mint keyset
     ///
-    /// Returns keys from local database if they are already stored.
-    /// If keys are not found locally, goes online to query the mint for the keyset and stores the [`Keys`] in local database.
+    /// 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> {
-        let keys = if let Some(keys) = self.localstore.get_keys(&keyset_id).await? {
-            keys
-        } else {
-            tracing::debug!(
-                "Keyset {} not in db fetching from mint {}",
-                keyset_id,
-                self.mint_url
-            );
-
-            let keys = self.client.get_mint_keyset(keyset_id).await?;
-
-            keys.verify_id()?;
-
-            self.localstore.add_keys(keys.clone()).await?;
-
-            keys.keys
-        };
-
-        Ok(keys)
+        self.metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.lock();
+                *ttl
+            })
+            .await?
+            .keys
+            .get(&keyset_id)
+            .map(|x| (*x.clone()).clone())
+            .ok_or(Error::UnknownKeySet)
     }
 
-    /// Get keysets from local database or go online if missing
-    ///
-    /// First checks the local database for cached keysets. If keysets are not found locally,
-    /// goes online to refresh keysets from the mint and updates the local database.
-    /// This is the main method for getting keysets in token operations that can work offline
-    /// but will fall back to online if needed.
+    /// Alias of get_mint_keysets, kept for backwards compatibility reasons
     #[instrument(skip(self))]
     pub async fn load_mint_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
-        match self
-            .localstore
-            .get_mint_keysets(self.mint_url.clone())
-            .await?
-        {
-            Some(keysets_info) => Ok(keysets_info),
-            None => {
-                // If we don't have any keysets, fetch them from the mint
-                let keysets = self.refresh_keysets().await?;
-                Ok(keysets)
-            }
-        }
+        self.get_mint_keysets().await
     }
 
-    /// Get keysets from local database only - pure offline operation
+    /// Get keysets from metadata cache (may fetch if not populated)
     ///
-    /// Only checks the local database for cached keysets. If keysets are not found locally,
-    /// 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))]
+    #[inline(always)]
     pub async fn get_mint_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
-        match self
-            .localstore
-            .get_mint_keysets(self.mint_url.clone())
+        let keysets = self
+            .metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.lock();
+                *ttl
+            })
             .await?
-        {
-            Some(keysets_info) => Ok(keysets_info),
-            None => Err(Error::UnknownKeySet),
+            .keysets
+            .iter()
+            .filter_map(|(_, keyset)| {
+                if keyset.unit == self.unit && keyset.active {
+                    Some((*keyset.clone()).clone())
+                } else {
+                    None
+                }
+            })
+            .collect::<Vec<_>>();
+
+        if !keysets.is_empty() {
+            Ok(keysets)
+        } else {
+            Err(Error::UnknownKeySet)
         }
     }
 
-    /// 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 always goes online to fetch the latest keyset information from the mint.
-    /// It updates the local database with the fetched keysets and ensures we have keys
-    /// for all active keysets. This is used when operations need the most up-to-date
-    /// keyset information and are willing to go online.
+    /// 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 and ensuring we have keys");
-        let _ = self.fetch_mint_info().await?;
+        tracing::debug!("Refreshing keysets from mint");
 
-        // Fetch all current keysets from mint
-        let keysets_response = self.client.get_mint_keysets().await?;
-        let all_keysets = keysets_response.keysets;
-
-        // Update local storage with keyset info
-        self.localstore
-            .add_mint_keysets(self.mint_url.clone(), all_keysets.clone())
-            .await?;
-
-        // Filter for active keysets matching our unit
-        let keysets: KeySetInfos = all_keysets.unit(self.unit.clone()).cloned().collect();
-
-        // Ensure we have keys for all active keysets
-        for keyset in &keysets {
-            self.load_keyset_keys(keyset.id).await?;
+        let keysets = self
+            .metadata_cache
+            .load_from_mint(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.lock();
+                *ttl
+            })
+            .await?
+            .keysets
+            .iter()
+            .filter_map(|(_, keyset)| {
+                if keyset.unit == self.unit && keyset.active {
+                    Some((*keyset.clone()).clone())
+                } else {
+                    None
+                }
+            })
+            .collect::<Vec<_>>();
+
+        if !keysets.is_empty() {
+            Ok(keysets)
+        } else {
+            Err(Error::UnknownKeySet)
         }
-
-        Ok(keysets)
     }
 
-    /// 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()
+        self.get_mint_keysets()
             .await?
             .active()
             .min_by_key(|k| k.input_fee_ppk)
@@ -120,47 +113,46 @@ impl Wallet {
             .ok_or(Error::NoActiveKeyset)
     }
 
-    /// Get the active keyset with the lowest fees from local database only - offline operation
+    /// Get the active keyset with the lowest fees from cache
     ///
-    /// Returns the active keyset with minimum input fees from cached keysets in the local database.
-    /// This is an offline operation that does not contact the mint. If no keysets are found locally,
-    /// 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> {
-        match self
-            .localstore
-            .get_mint_keysets(self.mint_url.clone())
+        self.metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.lock();
+                *ttl
+            })
             .await?
-        {
-            Some(keysets_info) => keysets_info
-                .into_iter()
-                .min_by_key(|k| k.input_fee_ppk)
-                .ok_or(Error::NoActiveKeyset),
-            None => Err(Error::UnknownKeySet),
-        }
+            .active_keysets
+            .iter()
+            .min_by_key(|k| k.input_fee_ppk)
+            .map(|ks| (**ks).clone())
+            .ok_or(Error::NoActiveKeyset)
     }
 
-    /// Get keyset fees and amounts for mint from local database only - 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 cached keysets in the local database. This is an offline operation that does
-    /// not contact the mint. If no keysets are found locally, 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
-            .localstore
-            .get_mint_keysets(self.mint_url.clone())
-            .await?
-            .ok_or(Error::UnknownKeySet)?;
+        let metadata = self
+            .metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.lock();
+                *ttl
+            })
+            .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,
                 (
                     keyset.input_fee_ppk,
-                    self.load_keyset_keys(keyset.id)
-                        .await?
-                        .iter()
+                    keys.iter()
                         .map(|(amount, _)| amount.to_u64())
                         .collect::<Vec<_>>(),
                 )
@@ -171,11 +163,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,

+ 1 - 3
crates/cdk/src/wallet/melt/melt_bolt11.rs

@@ -49,8 +49,6 @@ impl Wallet {
         request: String,
         options: Option<MeltOptions>,
     ) -> Result<MeltQuote, Error> {
-        self.refresh_keysets().await?;
-
         let invoice = Bolt11Invoice::from_str(&request)?;
 
         let quote_request = MeltQuoteBolt11Request {
@@ -390,7 +388,7 @@ impl Wallet {
         let available_proofs = self.get_unspent_proofs().await?;
 
         let active_keyset_ids = self
-            .refresh_keysets()
+            .get_mint_keysets()
             .await?
             .into_iter()
             .map(|k| k.id)

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

@@ -0,0 +1,616 @@
+//! 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::{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::parking_lot::RwLock;
+use cdk_common::task::spawn;
+use cdk_common::{KeySet, MintInfo};
+use tokio::sync::Mutex;
+
+use crate::nuts::Id;
+use crate::wallet::MintConnector;
+#[cfg(feature = "auth")]
+use crate::wallet::{AuthMintConnector, AuthWallet};
+use crate::{Error, Wallet};
+
+/// 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)]
+pub struct FreshnessStatus {
+    /// Whether this data has been successfully fetched at least once
+    pub is_populated: bool,
+
+    /// A future time when the cache would be considered as staled.
+    pub updated_at: Instant,
+
+    /// Monotonically increasing version number (for database sync tracking)
+    version: usize,
+}
+
+impl Default for FreshnessStatus {
+    fn default() -> Self {
+        Self {
+            is_populated: false,
+            updated_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. A `Mutex` ensures only one fetch
+/// operation runs at a time, with other callers waiting and re-reading cache.
+///
+/// # 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>>>,
+
+    /// Mutex to ensure only one fetch operation runs at a time
+    /// Other callers wait for the lock, then re-read the updated cache
+    fetch_lock: Arc<Mutex<()>>,
+}
+
+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 Wallet {
+    /// Sets the metadata cache TTL
+    pub fn set_metadata_cache_ttl(&self, ttl: Option<Duration>) {
+        let mut guarded_ttl = self.metadata_cache_ttl.lock();
+        *guarded_ttl = ttl;
+    }
+
+    /// Get information about metadata cache info
+    pub fn get_metadata_cache_info(&self) -> FreshnessStatus {
+        self.metadata_cache.metadata.load().status.clone()
+    }
+}
+
+#[cfg(feature = "auth")]
+impl AuthWallet {
+    /// Get information about metadata cache info
+    pub fn get_metadata_cache_info(&self) -> FreshnessStatus {
+        self.metadata_cache.metadata.load().auth_status.clone()
+    }
+}
+
+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, None);
+    /// // 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()),
+            fetch_lock: Arc::new(Mutex::new(())),
+        }
+    }
+
+    /// 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.
+    ///
+    /// Uses a mutex to ensure only one fetch runs at a time. If multiple
+    /// callers request a fetch simultaneously, only one performs the HTTP
+    /// request while others wait for the lock, then return the updated cache.
+    ///
+    /// 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
+    /// * `ttl` - Optional TTL, if not provided it is assumed that any cached data is good enough
+    ///
+    /// # 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>,
+        ttl: Option<Duration>,
+    ) -> Result<Arc<MintMetadata>, Error> {
+        // Acquire lock to ensure only one fetch at a time
+        let current_version = self.metadata.load().status.version;
+        let _guard = self.fetch_lock.lock().await;
+
+        // Check if another caller already updated the cache while we waited
+        let current_metadata = self.metadata.load().clone();
+        if current_metadata.status.is_populated
+            && ttl
+                .map(|ttl| current_metadata.status.updated_at + ttl > Instant::now())
+                .unwrap_or(true)
+            && current_metadata.status.version > current_version
+        {
+            // Cache was just updated by another caller - return it
+            tracing::debug!(
+                "Cache was updated while waiting for fetch lock, returning cached data"
+            );
+            return Ok(current_metadata);
+        }
+
+        // Perform the fetch
+        #[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 and it is still valid, 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)
+    /// * `ttl` - Optional TTL, if not provided it is assumed that any cached data is good enough
+    ///
+    /// # 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>,
+        ttl: Option<Duration>,
+    ) -> 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
+            && ttl
+                .map(|ttl| cached_metadata.status.updated_at + ttl > Instant::now())
+                .unwrap_or(true)
+        {
+            // 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, ttl).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
+            && cached_metadata.auth_status.updated_at > Instant::now()
+        {
+            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);
+        }
+
+        // Acquire fetch lock to ensure only one auth fetch at a time
+        let _guard = self.fetch_lock.lock().await;
+
+        // Re-check if auth data was updated while waiting for lock
+        let current_metadata = self.metadata.load().clone();
+        if current_metadata.auth_status.is_populated
+            && current_metadata.auth_status.updated_at > Instant::now()
+        {
+            tracing::debug!(
+                "Auth cache was updated while waiting for fetch lock, returning cached data"
+            );
+            return Ok(current_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.updated_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.updated_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
+    }
+}

+ 26 - 6
crates/cdk/src/wallet/mod.rs

@@ -1,12 +1,15 @@
 #![doc = include_str!("./README.md")]
 
 use std::collections::HashMap;
+use std::fmt::Debug;
 use std::str::FromStr;
 use std::sync::atomic::AtomicBool;
 use std::sync::Arc;
+use std::time::Duration;
 
 use cdk_common::amount::FeeAndAmounts;
 use cdk_common::database::{self, WalletDatabase};
+use cdk_common::parking_lot::Mutex;
 use cdk_common::subscription::WalletParams;
 use getrandom::getrandom;
 use subscription::{ActiveSubscription, SubscriptionManager};
@@ -28,6 +31,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;
@@ -42,6 +46,7 @@ mod issue;
 mod keysets;
 mod melt;
 mod mint_connector;
+mod mint_metadata_cache;
 pub mod multi_mint_wallet;
 pub mod payment_request;
 mod proofs;
@@ -86,8 +91,11 @@ pub struct Wallet {
     pub unit: CurrencyUnit,
     /// Storage backend
     pub localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+    /// 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,
+    metadata_cache_ttl: Arc<Mutex<Option<Duration>>>,
     #[cfg(feature = "auth")]
     auth_wallet: Arc<RwLock<Option<AuthWallet>>>,
     seed: [u8; 64],
@@ -217,12 +225,18 @@ 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, {
+                let ttl = self.metadata_cache_ttl.lock();
+                *ttl
+            })
+            .await?;
 
         for keyset_id in proofs_per_keyset.keys() {
-            let mint_keyset_info = self
-                .localstore
-                .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);
         }
@@ -236,9 +250,14 @@ 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
-            .localstore
-            .get_keyset_by_id(keyset_id)
+            .metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.lock();
+                *ttl
+            })
             .await?
+            .keysets
+            .get(keyset_id)
             .ok_or(Error::UnknownKeySet)?
             .input_fee_ppk;
 
@@ -307,6 +326,7 @@ impl Wallet {
                                 self.mint_url.clone(),
                                 None,
                                 self.localstore.clone(),
+                                self.metadata_cache.clone(),
                                 mint_info.protected_endpoints(),
                                 oidc_client,
                             );

+ 4 - 30
crates/cdk/src/wallet/multi_mint_wallet.rs

@@ -11,6 +11,7 @@ use std::sync::Arc;
 use anyhow::Result;
 use cdk_common::database;
 use cdk_common::database::WalletDatabase;
+use cdk_common::task::spawn;
 use cdk_common::wallet::{Transaction, TransactionDirection};
 use tokio::sync::RwLock;
 use tracing::instrument;
@@ -914,20 +915,7 @@ impl MultiMintWallet {
             let target_mint_url = target_mint_url.clone();
 
             // Spawn parallel transfer task
-            #[cfg(not(target_arch = "wasm32"))]
-            let task = tokio::spawn(async move {
-                self_clone
-                    .transfer(
-                        &source_mint_url,
-                        &target_mint_url,
-                        TransferMode::ExactReceive(transfer_amount),
-                    )
-                    .await
-                    .map(|result| result.amount_received)
-            });
-
-            #[cfg(target_arch = "wasm32")]
-            let task = tokio::task::spawn_local(async move {
+            let task = spawn(async move {
                 self_clone
                     .transfer(
                         &source_mint_url,
@@ -1342,14 +1330,7 @@ impl MultiMintWallet {
             let amount_msat = u64::from(amount) * 1000;
             let options = Some(MeltOptions::new_mpp(amount_msat));
 
-            #[cfg(not(target_arch = "wasm32"))]
-            let task = tokio::spawn(async move {
-                let quote = wallet.melt_quote(bolt11_clone, options).await;
-                (mint_url_clone, quote)
-            });
-
-            #[cfg(target_arch = "wasm32")]
-            let task = tokio::task::spawn_local(async move {
+            let task = spawn(async move {
                 let quote = wallet.melt_quote(bolt11_clone, options).await;
                 (mint_url_clone, quote)
             });
@@ -1398,14 +1379,7 @@ impl MultiMintWallet {
 
             let mint_url_clone = mint_url.clone();
 
-            #[cfg(not(target_arch = "wasm32"))]
-            let task = tokio::spawn(async move {
-                let melted = wallet.melt(&quote_id).await;
-                (mint_url_clone, melted)
-            });
-
-            #[cfg(target_arch = "wasm32")]
-            let task = tokio::task::spawn_local(async move {
+            let task = spawn(async move {
                 let melted = wallet.melt(&quote_id).await;
                 (mint_url_clone, melted)
             });

+ 0 - 2
crates/cdk/src/wallet/receive.rs

@@ -28,8 +28,6 @@ impl Wallet {
     ) -> Result<Amount, Error> {
         let mint_url = &self.mint_url;
 
-        self.refresh_keysets().await?;
-
         let active_keyset_id = self.fetch_active_keyset().await?.id;
 
         let keys = self.load_keyset_keys(active_keyset_id).await?;

+ 10 - 6
crates/cdk/src/wallet/swap.rs

@@ -21,8 +21,6 @@ impl Wallet {
         spending_conditions: Option<SpendingConditions>,
         include_fees: bool,
     ) -> Result<Option<Proofs>, Error> {
-        self.refresh_keysets().await?;
-
         tracing::info!("Swapping");
         let mint_url = &self.mint_url;
         let unit = &self.unit;
@@ -50,10 +48,16 @@ impl Wallet {
             .await?;
 
         let active_keys = self
-            .localstore
-            .get_keys(&active_keyset_id)
+            .metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.lock();
+                *ttl
+            })
             .await?
-            .ok_or(Error::NoActiveKeyset)?;
+            .keys
+            .get(&active_keyset_id)
+            .ok_or(Error::UnknownKeySet)?
+            .clone();
 
         let post_swap_proofs = construct_proofs(
             swap_response.signatures,
@@ -175,7 +179,7 @@ impl Wallet {
         ensure_cdk!(proofs_sum >= amount, Error::InsufficientFunds);
 
         let active_keyset_ids = self
-            .refresh_keysets()
+            .get_mint_keysets()
             .await?
             .active()
             .map(|k| k.id)

+ 2 - 2
misc/fake_itests.sh

@@ -175,7 +175,7 @@ done
 
 # Run first test
 echo "Running fake_wallet test"
-cargo test -p cdk-integration-tests --test fake_wallet
+cargo test -p cdk-integration-tests --test fake_wallet -- --nocapture
 status1=$?
 
 # Exit immediately if the first test failed
@@ -186,7 +186,7 @@ fi
 
 # Run second test only if the first one succeeded
 echo "Running happy_path_mint_wallet test"
-cargo test -p cdk-integration-tests --test happy_path_mint_wallet
+cargo test -p cdk-integration-tests --test happy_path_mint_wallet --  --nocapture
 status2=$?
 
 # Exit if the second test failed