ソースを参照

feat: add keyset_amounts table to track issued and redeemed amounts (#1247)

Fixes #1130

  Introduces a materialized keyset_amounts table to track total issued
  and redeemed amounts per keyset. This replaces the O(n) approach of
  querying all proofs/signatures on every call with O(1) lookups.

  Implementation:
  - New keyset_amounts table with total_issued and total_redeemed columns
  - Incremental updates via INSERT...ON CONFLICT upserts for atomicity
  - Migration backfills existing data from proof and blind_signature tables
  - Added comprehensive tests for mint, melt, swap, and multi-keyset scenarios
C 23 時間 前
コミット
e14469a8fa

+ 9 - 0
crates/cdk-common/src/database/mint/mod.rs

@@ -304,11 +304,15 @@ pub trait ProofsDatabase {
     ) -> Result<Vec<PublicKey>, Self::Err>;
     /// Get [`Proofs`] state
     async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result<Vec<Option<State>>, Self::Err>;
+
     /// Get [`Proofs`] by state
     async fn get_proofs_by_keyset_id(
         &self,
         keyset_id: &Id,
     ) -> Result<(Proofs, Vec<Option<State>>), Self::Err>;
+
+    /// Get total proofs redeemed by keyset id
+    async fn get_total_redeemed(&self) -> Result<HashMap<Id, Amount>, Self::Err>;
 }
 
 #[async_trait]
@@ -343,16 +347,21 @@ pub trait SignaturesDatabase {
         &self,
         blinded_messages: &[PublicKey],
     ) -> Result<Vec<Option<BlindSignature>>, Self::Err>;
+
     /// Get [`BlindSignature`]s for keyset_id
     async fn get_blind_signatures_for_keyset(
         &self,
         keyset_id: &Id,
     ) -> Result<Vec<BlindSignature>, Self::Err>;
+
     /// Get [`BlindSignature`]s for quote
     async fn get_blind_signatures_for_quote(
         &self,
         quote_id: &QuoteId,
     ) -> Result<Vec<BlindSignature>, Self::Err>;
+
+    /// Get total amount issued by keyset id
+    async fn get_total_issued(&self) -> Result<HashMap<Id, Amount>, Self::Err>;
 }
 
 #[async_trait]

+ 58 - 0
crates/cdk-integration-tests/tests/integration_tests_pure.rs

@@ -183,6 +183,19 @@ async fn test_mint_nut06() {
         .expect("Failed to get balance");
     assert_eq!(Amount::from(64), balance_alice);
 
+    // Verify keyset amounts after minting
+    let keyset_id = mint_bob.pubkeys().keysets.first().unwrap().id;
+    let total_issued = mint_bob.total_issued().await.unwrap();
+    let issued_amount = total_issued
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    assert_eq!(
+        issued_amount,
+        Amount::from(64),
+        "Should have issued 64 sats"
+    );
+
     let transaction = wallet_alice
         .list_transactions(None)
         .await
@@ -753,6 +766,28 @@ async fn test_mint_change_with_fee_melt() {
     .await
     .expect("Failed to fund wallet");
 
+    let keyset_id = mint_bob.pubkeys().keysets.first().unwrap().id;
+
+    // Check amounts after minting
+    let total_issued = mint_bob.total_issued().await.unwrap();
+    let total_redeemed = mint_bob.total_redeemed().await.unwrap();
+    let initial_issued = total_issued.get(&keyset_id).copied().unwrap_or_default();
+    let initial_redeemed = total_redeemed
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    assert_eq!(
+        initial_issued,
+        Amount::from(100),
+        "Should have issued 100 sats, got {:?}",
+        total_issued
+    );
+    assert_eq!(
+        initial_redeemed,
+        Amount::ZERO,
+        "Should have redeemed 0 sats initially, "
+    );
+
     let proofs = wallet_alice
         .get_unspent_proofs()
         .await
@@ -771,6 +806,29 @@ async fn test_mint_change_with_fee_melt() {
         .unwrap();
 
     assert_eq!(w.change.unwrap().total_amount().unwrap(), 97.into());
+
+    // Check amounts after melting
+    // Melting redeems 100 sats and issues 97 sats as change
+    let total_issued = mint_bob.total_issued().await.unwrap();
+    let total_redeemed = mint_bob.total_redeemed().await.unwrap();
+    let after_issued = total_issued
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let after_redeemed = total_redeemed
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    assert_eq!(
+        after_issued,
+        Amount::from(197),
+        "Should have issued 197 sats total (100 initial + 97 change)"
+    );
+    assert_eq!(
+        after_redeemed,
+        Amount::from(100),
+        "Should have redeemed 100 sats from the melt"
+    );
 }
 /// Tests concurrent double-spending attempts by trying to use the same proofs
 /// in 3 swap transactions simultaneously using tokio tasks

+ 299 - 0
crates/cdk-integration-tests/tests/test_swap_flow.rs

@@ -58,6 +58,29 @@ async fn test_swap_happy_path() {
         .expect("Could not get proofs");
 
     let keyset_id = get_keyset_id(&mint).await;
+
+    // Check initial amounts after minting
+    let total_issued = mint.total_issued().await.unwrap();
+    let total_redeemed = mint.total_redeemed().await.unwrap();
+    let initial_issued = total_issued
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let initial_redeemed = total_redeemed
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    assert_eq!(
+        initial_issued,
+        Amount::from(100),
+        "Should have issued 100 sats"
+    );
+    assert_eq!(
+        initial_redeemed,
+        Amount::ZERO,
+        "Should have redeemed 0 sats initially"
+    );
+
     let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
 
     // Create swap request for same amount (100 sats)
@@ -117,6 +140,29 @@ async fn test_swap_happy_path() {
         swap_response.signatures.len(),
         "All signatures should be saved"
     );
+
+    // Check keyset amounts after swap
+    // Swap redeems old proofs (100 sats) and issues new proofs (100 sats)
+    let total_issued = mint.total_issued().await.unwrap();
+    let total_redeemed = mint.total_redeemed().await.unwrap();
+    let after_issued = total_issued
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let after_redeemed = total_redeemed
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    assert_eq!(
+        after_issued,
+        Amount::from(200),
+        "Should have issued 200 sats total (initial 100 + swap 100)"
+    );
+    assert_eq!(
+        after_redeemed,
+        Amount::from(100),
+        "Should have redeemed 100 sats from the swap"
+    );
 }
 
 /// Tests that duplicate blinded messages are rejected:
@@ -901,3 +947,256 @@ async fn test_swap_proof_state_consistency() {
         }
     }
 }
+
+/// Tests that wallet correctly increments keyset counters when receiving proofs
+/// from multiple keysets and then performing operations with them.
+///
+/// This test validates:
+/// 1. Wallet can receive proofs from multiple different keysets
+/// 2. Counter is correctly incremented for the target keyset during swap
+/// 3. Database maintains separate counters for each keyset
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_wallet_multi_keyset_counter_updates() {
+    setup_tracing();
+    let mint = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Fund wallet with initial 100 sats using first keyset
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let first_keyset_id = get_keyset_id(&mint).await;
+
+    // Rotate to a second keyset
+    mint.rotate_keyset(CurrencyUnit::Sat, 32, 0)
+        .await
+        .expect("Failed to rotate keyset");
+
+    // Wait for keyset rotation to propagate
+    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+    // Refresh wallet keysets to know about the new keyset
+    wallet
+        .refresh_keysets()
+        .await
+        .expect("Failed to refresh wallet keysets");
+
+    // Fund wallet again with 100 sats using second keyset
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet with second keyset");
+
+    let second_keyset_id = mint
+        .pubkeys()
+        .keysets
+        .iter()
+        .find(|k| k.id != first_keyset_id)
+        .expect("Should have second keyset")
+        .id;
+
+    // Verify we now have proofs from two different keysets
+    let all_proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keysets_in_use: std::collections::HashSet<_> =
+        all_proofs.iter().map(|p| p.keyset_id).collect();
+
+    assert_eq!(
+        keysets_in_use.len(),
+        2,
+        "Should have proofs from 2 different keysets"
+    );
+    assert!(
+        keysets_in_use.contains(&first_keyset_id),
+        "Should have proofs from first keyset"
+    );
+    assert!(
+        keysets_in_use.contains(&second_keyset_id),
+        "Should have proofs from second keyset"
+    );
+
+    // Get initial total issued and redeemed for both keysets before swap
+    let total_issued_before = mint.total_issued().await.unwrap();
+    let total_redeemed_before = mint.total_redeemed().await.unwrap();
+
+    let first_keyset_issued_before = total_issued_before
+        .get(&first_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let first_keyset_redeemed_before = total_redeemed_before
+        .get(&first_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+
+    let second_keyset_issued_before = total_issued_before
+        .get(&second_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let second_keyset_redeemed_before = total_redeemed_before
+        .get(&second_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+
+    tracing::info!(
+        "Before swap - First keyset: issued={}, redeemed={}",
+        first_keyset_issued_before,
+        first_keyset_redeemed_before
+    );
+    tracing::info!(
+        "Before swap - Second keyset: issued={}, redeemed={}",
+        second_keyset_issued_before,
+        second_keyset_redeemed_before
+    );
+
+    // Both keysets should have issued 100 sats
+    assert_eq!(
+        first_keyset_issued_before,
+        Amount::from(100),
+        "First keyset should have issued 100 sats"
+    );
+    assert_eq!(
+        second_keyset_issued_before,
+        Amount::from(100),
+        "Second keyset should have issued 100 sats"
+    );
+    // Neither should have redeemed anything yet
+    assert_eq!(
+        first_keyset_redeemed_before,
+        Amount::ZERO,
+        "First keyset should have redeemed 0 sats before swap"
+    );
+    assert_eq!(
+        second_keyset_redeemed_before,
+        Amount::ZERO,
+        "Second keyset should have redeemed 0 sats before swap"
+    );
+
+    // Now perform a swap with all proofs - this should only increment the counter
+    // for the active (second) keyset, not for the first keyset
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    let total_amount = all_proofs.total_amount().expect("Should get total amount");
+
+    // Create swap using the active (second) keyset
+    let preswap = PreMintSecrets::random(
+        second_keyset_id,
+        total_amount,
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_request = SwapRequest::new(all_proofs.clone(), preswap.blinded_messages());
+
+    // Execute the swap
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Swap should succeed");
+
+    // Verify response
+    assert_eq!(
+        swap_response.signatures.len(),
+        preswap.blinded_messages().len(),
+        "Should receive signature for each blinded message"
+    );
+
+    // All the new proofs should be from the second (active) keyset
+    let keys = mint
+        .pubkeys()
+        .keysets
+        .iter()
+        .find(|k| k.id == second_keyset_id)
+        .expect("Should find second keyset")
+        .keys
+        .clone();
+
+    let new_proofs = construct_proofs(
+        swap_response.signatures,
+        preswap.rs(),
+        preswap.secrets(),
+        &keys,
+    )
+    .expect("Failed to construct proofs");
+
+    // Verify all new proofs use the second keyset
+    for proof in &new_proofs {
+        assert_eq!(
+            proof.keyset_id, second_keyset_id,
+            "All new proofs should use the active (second) keyset"
+        );
+    }
+
+    // Verify total issued and redeemed after swap
+    let total_issued_after = mint.total_issued().await.unwrap();
+    let total_redeemed_after = mint.total_redeemed().await.unwrap();
+
+    let first_keyset_issued_after = total_issued_after
+        .get(&first_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let first_keyset_redeemed_after = total_redeemed_after
+        .get(&first_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+
+    let second_keyset_issued_after = total_issued_after
+        .get(&second_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let second_keyset_redeemed_after = total_redeemed_after
+        .get(&second_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+
+    tracing::info!(
+        "After swap - First keyset: issued={}, redeemed={}",
+        first_keyset_issued_after,
+        first_keyset_redeemed_after
+    );
+    tracing::info!(
+        "After swap - Second keyset: issued={}, redeemed={}",
+        second_keyset_issued_after,
+        second_keyset_redeemed_after
+    );
+
+    // After swap:
+    // - First keyset: issued stays 100, redeemed increases by 100 (all its proofs were spent in swap)
+    // - Second keyset: issued increases by 200 (original 100 + new 100 from swap output),
+    //                  redeemed increases by 100 (its proofs from first funding were spent)
+    assert_eq!(
+        first_keyset_issued_after,
+        Amount::from(100),
+        "First keyset issued should stay 100 sats (no new issuance)"
+    );
+    assert_eq!(
+        first_keyset_redeemed_after,
+        Amount::from(100),
+        "First keyset should have redeemed 100 sats (all its proofs spent in swap)"
+    );
+
+    assert_eq!(
+        second_keyset_issued_after,
+        Amount::from(300),
+        "Second keyset should have issued 300 sats total (100 initial + 100 the second funding + 100 from swap output from the old keyset)"
+    );
+    assert_eq!(
+        second_keyset_redeemed_after,
+        Amount::from(100),
+        "Second keyset should have redeemed 100 sats (its proofs from initial funding spent in swap)"
+    );
+
+    // The test verifies that:
+    // 1. We can have proofs from multiple keysets in a wallet
+    // 2. Swap operation processes inputs from any keyset but creates outputs using active keyset
+    // 3. The keyset_counter table correctly handles counters for different keysets independently
+    // 4. The database upsert logic in increment_keyset_counter works for multiple keysets
+    // 5. Total issued and redeemed are tracked correctly per keyset during multi-keyset swaps
+}

+ 25 - 0
crates/cdk-sql-common/src/mint/migrations/postgres/20251102000000_create_keyset_amounts.sql

@@ -0,0 +1,25 @@
+-- Create keyset_amounts table with total_issued and total_redeemed columns
+CREATE TABLE IF NOT EXISTS keyset_amounts (
+    keyset_id TEXT PRIMARY KEY NOT NULL,
+    total_issued BIGINT NOT NULL DEFAULT 0,
+    total_redeemed BIGINT NOT NULL DEFAULT 0
+);
+
+-- Prefill with issued and redeemed amounts using FULL OUTER JOIN
+INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
+SELECT
+    COALESCE(bs.keyset_id, p.keyset_id) as keyset_id,
+    COALESCE(bs.total_issued, 0) as total_issued,
+    COALESCE(p.total_redeemed, 0) as total_redeemed
+FROM (
+    SELECT keyset_id, SUM(amount) as total_issued
+    FROM blind_signature
+    WHERE c IS NOT NULL
+    GROUP BY keyset_id
+) bs
+FULL OUTER JOIN (
+    SELECT keyset_id, SUM(amount) as total_redeemed
+    FROM proof
+    WHERE state = 'SPENT'
+    GROUP BY keyset_id
+) p ON bs.keyset_id = p.keyset_id;

+ 30 - 0
crates/cdk-sql-common/src/mint/migrations/sqlite/20251102000000_create_keyset_amounts.sql

@@ -0,0 +1,30 @@
+-- Create keyset_amounts table with total_issued and total_redeemed columns
+CREATE TABLE IF NOT EXISTS keyset_amounts (
+    keyset_id TEXT PRIMARY KEY NOT NULL,
+    total_issued INTEGER NOT NULL DEFAULT 0,
+    total_redeemed INTEGER NOT NULL DEFAULT 0
+);
+
+-- Prefill with issued amounts
+INSERT OR IGNORE INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
+SELECT keyset_id, SUM(amount) as total_issued, 0 as total_redeemed
+FROM blind_signature
+WHERE c IS NOT NULL
+GROUP BY keyset_id;
+
+-- Update with redeemed amounts
+UPDATE keyset_amounts
+SET total_redeemed = (
+    SELECT COALESCE(SUM(amount), 0)
+    FROM proof
+    WHERE proof.keyset_id = keyset_amounts.keyset_id
+    AND proof.state = 'SPENT'
+);
+
+-- Insert keysets that only have redeemed amounts (no issued)
+INSERT OR IGNORE INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
+SELECT keyset_id, 0 as total_issued, SUM(amount) as total_redeemed
+FROM proof
+WHERE state = 'SPENT'
+AND keyset_id NOT IN (SELECT keyset_id FROM keyset_amounts)
+GROUP BY keyset_id;

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

@@ -219,6 +219,23 @@ where
             .execute(&self.inner)
             .await?;
 
+        if new_state == State::Spent {
+            query(
+                r#"
+                INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
+                SELECT keyset_id, 0, COALESCE(SUM(amount), 0)
+                FROM proof
+                WHERE y IN (:ys)
+                GROUP BY keyset_id
+                ON CONFLICT (keyset_id)
+                DO UPDATE SET total_redeemed = keyset_amounts.total_redeemed + EXCLUDED.total_redeemed
+                "#,
+            )?
+            .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect())
+            .execute(&self.inner)
+            .await?;
+        }
+
         Ok(ys.iter().map(|y| current_states.remove(y)).collect())
     }
 
@@ -1618,6 +1635,25 @@ where
         .into_iter()
         .unzip())
     }
+
+    /// Get total proofs redeemed by keyset id
+    async fn get_total_redeemed(&self) -> Result<HashMap<Id, Amount>, Self::Err> {
+        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
+        query(
+            r#"
+            SELECT
+                keyset_id,
+                total_redeemed as amount
+            FROM
+                keyset_amounts
+        "#,
+        )?
+        .fetch_all(&*conn)
+        .await?
+        .into_iter()
+        .map(sql_row_to_hashmap_amount)
+        .collect()
+    }
 }
 
 #[async_trait]
@@ -1698,6 +1734,19 @@ where
                     .bind("signed_time", current_time as i64)
                     .execute(&self.inner)
                     .await?;
+
+                    query(
+                        r#"
+                        INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
+                        VALUES (:keyset_id, :amount, 0)
+                        ON CONFLICT (keyset_id)
+                        DO UPDATE SET total_issued = keyset_amounts.total_issued + EXCLUDED.total_issued
+                        "#,
+                    )?
+                    .bind("amount", u64::from(signature.amount) as i64)
+                    .bind("keyset_id", signature.keyset_id.to_string())
+                    .execute(&self.inner)
+                    .await?;
                 }
                 Some((c, _dleq_e, _dleq_s)) => {
                     // Blind message exists: check if c is NULL
@@ -1725,6 +1774,19 @@ where
                             .bind("amount", u64::from(signature.amount) as i64)
                             .execute(&self.inner)
                             .await?;
+
+                            query(
+                                r#"
+                                INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
+                                VALUES (:keyset_id, :amount, 0)
+                                ON CONFLICT (keyset_id)
+                                DO UPDATE SET total_issued = keyset_amounts.total_issued + EXCLUDED.total_issued
+                                "#,
+                            )?
+                            .bind("amount", u64::from(signature.amount) as i64)
+                            .bind("keyset_id", signature.keyset_id.to_string())
+                            .execute(&self.inner)
+                            .await?;
                         }
                         _ => {
                             // Blind message already has c: Error
@@ -1907,6 +1969,25 @@ where
         .map(sql_row_to_blind_signature)
         .collect::<Result<Vec<BlindSignature>, _>>()?)
     }
+
+    /// Get total proofs redeemed by keyset id
+    async fn get_total_issued(&self) -> Result<HashMap<Id, Amount>, Self::Err> {
+        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
+        query(
+            r#"
+            SELECT
+                keyset_id,
+                total_issued as amount
+            FROM
+                keyset_amounts
+        "#,
+        )?
+        .fetch_all(&*conn)
+        .await?
+        .into_iter()
+        .map(sql_row_to_hashmap_amount)
+        .collect()
+    }
 }
 
 #[async_trait]
@@ -2482,6 +2563,20 @@ fn sql_row_to_proof(row: Vec<Column>) -> Result<Proof, Error> {
     })
 }
 
+fn sql_row_to_hashmap_amount(row: Vec<Column>) -> Result<(Id, Amount), Error> {
+    unpack_into!(
+        let (
+            keyset_id, amount
+        ) = row
+    );
+
+    let amount: u64 = column_as_number!(amount);
+    Ok((
+        column_as_string!(keyset_id, Id::from_str, Id::from_bytes),
+        Amount::from(amount),
+    ))
+}
+
 fn sql_row_to_proof_with_state(row: Vec<Column>) -> Result<(Proof, Option<State>), Error> {
     unpack_into!(
         let (

+ 11 - 31
crates/cdk/src/mint/mod.rs

@@ -951,21 +951,10 @@ impl Mint {
         global::inc_in_flight_requests("total_issued");
 
         let result = async {
-            let keysets = self.keysets().keysets;
-
-            let mut total_issued = HashMap::new();
-
-            for keyset in keysets {
-                let blinded = self
-                    .localstore
-                    .get_blind_signatures_for_keyset(&keyset.id)
-                    .await?;
-
-                let total = Amount::try_sum(blinded.iter().map(|b| b.amount))?;
-
-                total_issued.insert(keyset.id, total);
+            let mut total_issued = self.localstore.get_total_issued().await?;
+            for keyset in self.keysets().keysets {
+                total_issued.entry(keyset.id).or_default();
             }
-
             Ok(total_issued)
         }
         .await;
@@ -985,28 +974,19 @@ impl Mint {
         #[cfg(feature = "prometheus")]
         global::inc_in_flight_requests("total_redeemed");
 
-        let keysets = self.signatory.keysets().await?;
-
-        let mut total_redeemed = HashMap::new();
-
-        for keyset in keysets.keysets {
-            let (proofs, state) = self.localstore.get_proofs_by_keyset_id(&keyset.id).await?;
-
-            let total_spent =
-                Amount::try_sum(proofs.iter().zip(state).filter_map(|(p, s)| {
-                    match s == Some(State::Spent) {
-                        true => Some(p.amount),
-                        false => None,
-                    }
-                }))?;
-
-            total_redeemed.insert(keyset.id, total_spent);
+        let total_redeemed = async {
+            let mut total_redeemed = self.localstore.get_total_redeemed().await?;
+            for keyset in self.keysets().keysets {
+                total_redeemed.entry(keyset.id).or_default();
+            }
+            Ok(total_redeemed)
         }
+        .await;
 
         #[cfg(feature = "prometheus")]
         global::dec_in_flight_requests("total_redeemed");
 
-        Ok(total_redeemed)
+        total_redeemed
     }
 }