Pārlūkot izejas kodu

feat: add keyset_amounts table to track issued and redeemed amounts

Fixes #1130

This commit introduces a new `keyset_amounts` table to efficiently track
the total issued and redeemed amounts per keyset, replacing the need to
calculate these values on-demand from the proof and blind_signature tables.
Cesar Rodas 3 mēneši atpakaļ
vecāks
revīzija
1849132672

+ 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

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

@@ -18,6 +18,7 @@ use cashu::{CurrencyUnit, Id, PreMintSecrets, SecretKey, SpendingConditions, Sta
 use cdk::mint::Mint;
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::Amount;
+use cdk_common::database::mint::{ProofsDatabase, SignaturesDatabase};
 use cdk_integration_tests::init_pure_tests::*;
 
 /// Helper to get the active keyset ID from a mint
@@ -58,6 +59,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 +141,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:

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

@@ -0,0 +1,24 @@
+-- Create keyset_amounts table
+CREATE TABLE IF NOT EXISTS keyset_amounts (
+    keyset_id TEXT NOT NULL,
+    type TEXT NOT NULL,
+    amount BIGINT NOT NULL DEFAULT 0,
+    PRIMARY KEY (keyset_id, type)
+);
+
+-- Create index for faster lookups
+CREATE INDEX IF NOT EXISTS idx_keyset_amounts_type ON keyset_amounts(type);
+
+-- Prefill with issued amounts (sum from blind_signature where c IS NOT NULL)
+INSERT INTO keyset_amounts (keyset_id, type, amount)
+SELECT keyset_id, 'issued', COALESCE(SUM(amount), 0)
+FROM blind_signature
+WHERE c IS NOT NULL
+GROUP BY keyset_id;
+
+-- Prefill with redeemed amounts (sum from proof where state = 'SPENT')
+INSERT INTO keyset_amounts (keyset_id, type, amount)
+SELECT keyset_id, 'redeemed', COALESCE(SUM(amount), 0)
+FROM proof
+WHERE state = 'SPENT'
+GROUP BY keyset_id;

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

@@ -0,0 +1,24 @@
+-- Create keyset_amounts table
+CREATE TABLE IF NOT EXISTS keyset_amounts (
+    keyset_id TEXT NOT NULL,
+    type TEXT NOT NULL,
+    amount INTEGER NOT NULL DEFAULT 0,
+    PRIMARY KEY (keyset_id, type)
+);
+
+-- Create index for faster lookups
+CREATE INDEX IF NOT EXISTS idx_keyset_amounts_type ON keyset_amounts(type);
+
+-- Prefill with issued amounts (sum from blind_signature where c IS NOT NULL)
+INSERT INTO keyset_amounts (keyset_id, type, amount)
+SELECT keyset_id, 'issued', COALESCE(SUM(amount), 0)
+FROM blind_signature
+WHERE c IS NOT NULL
+GROUP BY keyset_id;
+
+-- Prefill with redeemed amounts (sum from proof where state = 'SPENT')
+INSERT INTO keyset_amounts (keyset_id, type, amount)
+SELECT keyset_id, 'redeemed', COALESCE(SUM(amount), 0)
+FROM proof
+WHERE state = 'SPENT'
+GROUP BY keyset_id;

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

@@ -219,6 +219,24 @@ where
             .execute(&self.inner)
             .await?;
 
+        if new_state == State::Spent {
+            // Update keyset_amounts table for redeemed amounts
+            query(
+                r#"
+                INSERT INTO keyset_amounts (keyset_id, type, amount)
+                SELECT keyset_id, 'redeemed', COALESCE(SUM(amount), 0)
+                FROM proof
+                WHERE y IN (:ys)
+                GROUP BY keyset_id
+                ON CONFLICT (keyset_id, type)
+                DO UPDATE SET amount = keyset_amounts.amount + EXCLUDED.amount
+                "#,
+            )?
+            .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 +1636,26 @@ 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,
+                amount
+            FROM
+                keyset_amounts
+            WHERE type = 'redeemed'
+        "#,
+        )?
+        .fetch_all(&*conn)
+        .await?
+        .into_iter()
+        .map(sql_row_to_hashmap_amount)
+        .collect()
+    }
 }
 
 #[async_trait]
@@ -1698,6 +1736,19 @@ where
                     .bind("signed_time", current_time as i64)
                     .execute(&self.inner)
                     .await?;
+
+                    query(
+                        r#"
+                        INSERT INTO keyset_amounts (keyset_id, type, amount)
+                        VALUES (:keyset_id, 'issued', :amount)
+                        ON CONFLICT (keyset_id, type)
+                        DO UPDATE SET amount = keyset_amounts.amount + :amount
+                        "#,
+                    )?
+                    .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 +1776,19 @@ where
                             .bind("amount", u64::from(signature.amount) as i64)
                             .execute(&self.inner)
                             .await?;
+
+                            query(
+                                r#"
+                                INSERT INTO keyset_amounts (keyset_id, type, amount)
+                                VALUES (:keyset_id, 'issued', :amount)
+                                ON CONFLICT (keyset_id, type)
+                                DO UPDATE SET amount = keyset_amounts.amount + :amount
+                                "#,
+                            )?
+                            .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 +1971,26 @@ 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,
+                amount
+            FROM
+                keyset_amounts
+            WHERE type = 'issued'
+        "#,
+        )?
+        .fetch_all(&*conn)
+        .await?
+        .into_iter()
+        .map(sql_row_to_hashmap_amount)
+        .collect()
+    }
 }
 
 #[async_trait]
@@ -2482,6 +2566,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 (

+ 10 - 26
crates/cdk/src/mint/mod.rs

@@ -952,18 +952,9 @@ impl Mint {
 
         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 keysets.into_iter().filter(|x| x.unit != CurrencyUnit::Auth) {
+                let _ = total_issued.entry(keyset.id).or_default();
             }
 
             Ok(total_issued)
@@ -986,21 +977,14 @@ impl Mint {
         global::inc_in_flight_requests("total_redeemed");
 
         let keysets = self.signatory.keysets().await?;
+        let mut total_redeemed = self.localstore.get_total_redeemed().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);
+        for keyset in keysets
+            .keysets
+            .into_iter()
+            .filter(|x| x.unit != CurrencyUnit::Auth)
+        {
+            let _ = total_redeemed.entry(keyset.id).or_default();
         }
 
         #[cfg(feature = "prometheus")]