Pārlūkot izejas kodu

feat: store melt_request (#1045)

thesimplekid 1 mēnesi atpakaļ
vecāks
revīzija
2dbb418db7

+ 56 - 20
crates/cdk-common/src/database/mint/mod.rs

@@ -2,6 +2,28 @@
 
 use std::collections::HashMap;
 
+use async_trait::async_trait;
+use cashu::quote_id::QuoteId;
+use cashu::{Amount, MintInfo};
+
+use super::Error;
+use crate::common::QuoteTTL;
+use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote};
+use crate::nuts::{
+    BlindSignature, BlindedMessage, CurrencyUnit, Id, MeltQuoteState, Proof, Proofs, PublicKey,
+    State,
+};
+use crate::payment::PaymentIdentifier;
+
+#[cfg(feature = "auth")]
+mod auth;
+
+#[cfg(feature = "test")]
+pub mod test;
+
+#[cfg(feature = "auth")]
+pub use auth::{MintAuthDatabase, MintAuthTransaction};
+
 /// Valid ASCII characters for namespace and key strings in KV store
 pub const KVSTORE_NAMESPACE_KEY_ALPHABET: &str =
     "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-";
@@ -62,26 +84,16 @@ pub fn validate_kvstore_params(
     Ok(())
 }
 
-use async_trait::async_trait;
-use cashu::quote_id::QuoteId;
-use cashu::{Amount, MintInfo};
-
-use super::Error;
-use crate::common::QuoteTTL;
-use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote};
-use crate::nuts::{
-    BlindSignature, CurrencyUnit, Id, MeltQuoteState, Proof, Proofs, PublicKey, State,
-};
-use crate::payment::PaymentIdentifier;
-
-#[cfg(feature = "auth")]
-mod auth;
-
-#[cfg(feature = "test")]
-pub mod test;
-
-#[cfg(feature = "auth")]
-pub use auth::{MintAuthDatabase, MintAuthTransaction};
+/// Information about a melt request stored in the database
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MeltRequestInfo {
+    /// Total amount of all input proofs in the melt request
+    pub inputs_amount: Amount,
+    /// Fee amount associated with the input proofs
+    pub inputs_fee: Amount,
+    /// Blinded messages for change outputs
+    pub change_outputs: Vec<BlindedMessage>,
+}
 
 /// KeysDatabaseWriter
 #[async_trait]
@@ -123,6 +135,24 @@ pub trait QuotesTransaction<'a> {
     /// Mint Quotes Database Error
     type Err: Into<Error> + From<Error>;
 
+    /// Add melt_request with quote_id, inputs_amount, and blinded_messages
+    async fn add_melt_request_and_blinded_messages(
+        &mut self,
+        quote_id: &QuoteId,
+        inputs_amount: Amount,
+        inputs_fee: Amount,
+        blinded_messages: &[BlindedMessage],
+    ) -> Result<(), Self::Err>;
+
+    /// Get melt_request and associated blinded_messages by quote_id
+    async fn get_melt_request_and_blinded_messages(
+        &mut self,
+        quote_id: &QuoteId,
+    ) -> Result<Option<MeltRequestInfo>, Self::Err>;
+
+    /// Delete melt_request and associated blinded_messages by quote_id
+    async fn delete_melt_request(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err>;
+
     /// Get [`MintMintQuote`] and lock it for update in this transaction
     async fn get_mint_quote(
         &mut self,
@@ -242,6 +272,12 @@ pub trait ProofsTransaction<'a> {
         ys: &[PublicKey],
         quote_id: Option<QuoteId>,
     ) -> Result<(), Self::Err>;
+
+    /// Get ys by quote id
+    async fn get_proof_ys_by_quote_id(
+        &self,
+        quote_id: &QuoteId,
+    ) -> Result<Vec<PublicKey>, Self::Err>;
 }
 
 /// Mint Proof Database trait

+ 2 - 0
crates/cdk-postgres/start_db_for_test.sh

@@ -4,6 +4,7 @@ pub static MIGRATIONS: &[(&str, &str, &str)] = &[
     ("postgres", "1_initial.sql", include_str!(r#"./migrations/postgres/1_initial.sql"#)),
     ("postgres", "2_remove_request_lookup_kind_constraints.sql", include_str!(r#"./migrations/postgres/2_remove_request_lookup_kind_constraints.sql"#)),
     ("postgres", "20250901090000_add_kv_store.sql", include_str!(r#"./migrations/postgres/20250901090000_add_kv_store.sql"#)),
+    ("postgres", "20250902140000_add_melt_request_and_blinded_messages.sql", include_str!(r#"./migrations/postgres/20250902140000_add_melt_request_and_blinded_messages.sql"#)),
     ("postgres", "20250903200000_add_signatory_amounts.sql", include_str!(r#"./migrations/postgres/20250903200000_add_signatory_amounts.sql"#)),
     ("sqlite", "1_fix_sqlx_migration.sql", include_str!(r#"./migrations/sqlite/1_fix_sqlx_migration.sql"#)),
     ("sqlite", "20240612124932_init.sql", include_str!(r#"./migrations/sqlite/20240612124932_init.sql"#)),
@@ -30,5 +31,6 @@ pub static MIGRATIONS: &[(&str, &str, &str)] = &[
     ("sqlite", "20250812132015_drop_melt_request.sql", include_str!(r#"./migrations/sqlite/20250812132015_drop_melt_request.sql"#)),
     ("sqlite", "20250819200000_remove_request_lookup_kind_constraints.sql", include_str!(r#"./migrations/sqlite/20250819200000_remove_request_lookup_kind_constraints.sql"#)),
     ("sqlite", "20250901090000_add_kv_store.sql", include_str!(r#"./migrations/sqlite/20250901090000_add_kv_store.sql"#)),
+    ("sqlite", "20250902140000_add_melt_request_and_blinded_messages.sql", include_str!(r#"./migrations/sqlite/20250902140000_add_melt_request_and_blinded_messages.sql"#)),
     ("sqlite", "20250903200000_add_signatory_amounts.sql", include_str!(r#"./migrations/sqlite/20250903200000_add_signatory_amounts.sql"#)),
 ];

+ 20 - 0
crates/cdk-sql-common/src/mint/migrations/postgres/20250902140000_add_melt_request_and_blinded_messages.sql

@@ -0,0 +1,20 @@
+-- Drop existing melt_request table and recreate with new schema
+DROP TABLE IF EXISTS melt_request;
+CREATE TABLE melt_request (
+    quote_id TEXT PRIMARY KEY,
+    inputs_amount INTEGER NOT NULL,
+    inputs_fee INTEGER NOT NULL,
+    FOREIGN KEY (quote_id) REFERENCES melt_quote(id)
+);
+
+-- Add blinded_messages table
+CREATE TABLE blinded_messages (
+    quote_id TEXT NOT NULL,
+    blinded_message BYTEA NOT NULL,
+    keyset_id TEXT NOT NULL,
+    amount INTEGER NOT NULL,
+    FOREIGN KEY (quote_id) REFERENCES melt_request(quote_id) ON DELETE CASCADE
+);
+
+-- Add index for faster lookups on blinded_messages
+CREATE INDEX blinded_messages_quote_id_index ON blinded_messages(quote_id);

+ 23 - 0
crates/cdk-sql-common/src/mint/migrations/sqlite/20250902140000_add_melt_request_and_blinded_messages.sql

@@ -0,0 +1,23 @@
+
+-- Drop existing melt_request table and recreate with new schema
+DROP TABLE IF EXISTS melt_request;
+CREATE TABLE melt_request (
+    quote_id TEXT PRIMARY KEY,
+    inputs_amount INTEGER NOT NULL,
+    inputs_fee INTEGER NOT NULL,
+    FOREIGN KEY (quote_id) REFERENCES melt_quote(id)
+);
+
+-- Add blinded_messages table
+CREATE TABLE blinded_messages (
+    quote_id TEXT NOT NULL,
+    blinded_message BLOB NOT NULL,
+    amount INTEGER NOT NULL DEFAULT 0,
+    keyset_id TEXT NOT NULL,
+    FOREIGN KEY (quote_id) REFERENCES melt_request(quote_id) ON DELETE CASCADE
+);
+
+-- Add index for faster lookups on blinded_messages
+CREATE INDEX blinded_messages_quote_id_index ON blinded_messages(quote_id);
+-- Create an index on keyset_id for better query performance
+CREATE INDEX blinded_messages_keyset_id_index ON blinded_messages(keyset_id);

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

@@ -32,8 +32,8 @@ use cdk_common::secret::Secret;
 use cdk_common::state::check_state_transition;
 use cdk_common::util::unix_time;
 use cdk_common::{
-    Amount, BlindSignature, BlindSignatureDleq, CurrencyUnit, Id, MeltQuoteState, MintInfo,
-    PaymentMethod, Proof, Proofs, PublicKey, SecretKey, State,
+    Amount, BlindSignature, BlindSignatureDleq, BlindedMessage, CurrencyUnit, Id, MeltQuoteState,
+    MintInfo, PaymentMethod, Proof, Proofs, PublicKey, SecretKey, State,
 };
 use lightning_invoice::Bolt11Invoice;
 use migrations::MIGRATIONS;
@@ -277,6 +277,33 @@ where
 
         Ok(())
     }
+
+    async fn get_proof_ys_by_quote_id(
+        &self,
+        quote_id: &QuoteId,
+    ) -> Result<Vec<PublicKey>, Self::Err> {
+        Ok(query(
+            r#"
+            SELECT
+                amount,
+                keyset_id,
+                secret,
+                c,
+                witness
+            FROM
+                proof
+            WHERE
+                quote_id = :quote_id
+            "#,
+        )?
+        .bind("quote_id", quote_id.to_string())
+        .fetch_all(&self.inner)
+        .await?
+        .into_iter()
+        .map(sql_row_to_proof)
+        .collect::<Result<Vec<Proof>, _>>()?
+        .ys()?)
+    }
 }
 
 #[async_trait]
@@ -564,6 +591,123 @@ where
 {
     type Err = Error;
 
+    async fn add_melt_request_and_blinded_messages(
+        &mut self,
+        quote_id: &QuoteId,
+        inputs_amount: Amount,
+        inputs_fee: Amount,
+        blinded_messages: &[BlindedMessage],
+    ) -> Result<(), Self::Err> {
+        query(
+            r#"
+            INSERT INTO melt_request
+            (quote_id, inputs_amount, inputs_fee)
+            VALUES
+            (:quote_id, :inputs_amount, :inputs_fee)
+            "#,
+        )?
+        .bind("quote_id", quote_id.to_string())
+        .bind("inputs_amount", inputs_amount.to_i64())
+        .bind("inputs_fee", inputs_fee.to_i64())
+        .execute(&self.inner)
+        .await?;
+
+        for message in blinded_messages {
+            query(
+                r#"
+                INSERT INTO blinded_messages
+                (quote_id, blinded_message, keyset_id, amount)
+                VALUES
+                (:quote_id, :blinded_message, :keyset_id, :amount)
+                "#,
+            )?
+            .bind("quote_id", quote_id.to_string())
+            .bind(
+                "blinded_message",
+                message.blinded_secret.to_bytes().to_vec(),
+            )
+            .bind("keyset_id", message.keyset_id.to_string())
+            .bind("amount", message.amount.to_i64())
+            .execute(&self.inner)
+            .await?;
+        }
+
+        Ok(())
+    }
+
+    async fn get_melt_request_and_blinded_messages(
+        &mut self,
+        quote_id: &QuoteId,
+    ) -> Result<Option<database::mint::MeltRequestInfo>, Self::Err> {
+        let melt_request_row = query(
+            r#"
+            SELECT inputs_amount, inputs_fee
+            FROM melt_request
+            WHERE quote_id = :quote_id
+            FOR UPDATE
+            "#,
+        )?
+        .bind("quote_id", quote_id.to_string())
+        .fetch_one(&self.inner)
+        .await?;
+
+        if let Some(row) = melt_request_row {
+            let inputs_amount: u64 = column_as_number!(row[0].clone());
+            let inputs_fee: u64 = column_as_number!(row[1].clone());
+
+            let blinded_messages_rows = query(
+                r#"
+                SELECT blinded_message, keyset_id, amount
+                FROM blinded_messages
+                WHERE quote_id = :quote_id
+                "#,
+            )?
+            .bind("quote_id", quote_id.to_string())
+            .fetch_all(&self.inner)
+            .await?;
+
+            let blinded_messages: Result<Vec<BlindedMessage>, Error> = blinded_messages_rows
+                .into_iter()
+                .map(|row| -> Result<BlindedMessage, Error> {
+                    let blinded_message_key =
+                        column_as_string!(&row[0], PublicKey::from_hex, PublicKey::from_slice);
+                    let keyset_id = column_as_string!(&row[1], Id::from_str, Id::from_bytes);
+                    let amount: u64 = column_as_number!(row[2].clone());
+
+                    Ok(BlindedMessage {
+                        blinded_secret: blinded_message_key,
+                        keyset_id,
+                        amount: Amount::from(amount),
+                        witness: None, // Not storing witness in database currently
+                    })
+                })
+                .collect();
+            let blinded_messages = blinded_messages?;
+
+            Ok(Some(database::mint::MeltRequestInfo {
+                inputs_amount: Amount::from(inputs_amount),
+                inputs_fee: Amount::from(inputs_fee),
+                change_outputs: blinded_messages,
+            }))
+        } else {
+            Ok(None)
+        }
+    }
+
+    async fn delete_melt_request(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err> {
+        query(
+            r#"
+            DELETE FROM melt_request
+            WHERE quote_id = :quote_id
+            "#,
+        )?
+        .bind("quote_id", quote_id.to_string())
+        .execute(&self.inner)
+        .await?;
+
+        Ok(())
+    }
+
     #[instrument(skip(self))]
     async fn increment_mint_quote_amount_paid(
         &mut self,
@@ -788,8 +932,6 @@ VALUES (:quote_id, :amount, :timestamp);
     }
 
     async fn add_melt_quote(&mut self, quote: mint::MeltQuote) -> Result<(), Self::Err> {
-        // First try to find and replace any expired UNPAID quotes with the same request_lookup_id
-
         // Now insert the new quote
         query(
             r#"
@@ -916,6 +1058,10 @@ VALUES (:quote_id, :amount, :timestamp);
         let old_state = quote.state;
         quote.state = state;
 
+        if state == MeltQuoteState::Unpaid || state == MeltQuoteState::Failed {
+            self.delete_melt_request(quote_id).await?;
+        }
+
         Ok((old_state, quote))
     }
 

+ 56 - 34
crates/cdk/src/mint/melt.rs

@@ -2,10 +2,10 @@ use std::str::FromStr;
 
 use anyhow::bail;
 use cdk_common::amount::amount_for_offer;
+use cdk_common::database::mint::MeltRequestInfo;
 use cdk_common::database::{self, MintTransaction};
 use cdk_common::melt::MeltQuoteRequest;
 use cdk_common::mint::MeltPaymentRequest;
-use cdk_common::nut00::ProofsMethods;
 use cdk_common::nut05::MeltMethodOptions;
 use cdk_common::payment::{
     Bolt11OutgoingPaymentOptions, Bolt12OutgoingPaymentOptions, OutgoingPaymentOptions,
@@ -501,6 +501,25 @@ impl Mint {
         input_verification: Verification,
         melt_request: &MeltRequest<QuoteId>,
     ) -> Result<(ProofWriter, MeltQuote), Error> {
+        let Verification {
+            amount: input_amount,
+            unit: input_unit,
+        } = input_verification;
+
+        ensure_cdk!(input_unit.is_some(), Error::UnsupportedUnit);
+
+        let mut proof_writer =
+            ProofWriter::new(self.localstore.clone(), self.pubsub_manager.clone());
+
+        proof_writer
+            .add_proofs(
+                tx,
+                melt_request.inputs(),
+                Some(melt_request.quote_id().to_owned()),
+            )
+            .await?;
+
+        // Only after proof verification succeeds, proceed with quote state check
         let (state, quote) = tx
             .update_melt_quote_state(melt_request.quote(), MeltQuoteState::Pending, None)
             .await?;
@@ -515,13 +534,6 @@ impl Mint {
         self.pubsub_manager
             .melt_quote_status(&quote, None, None, MeltQuoteState::Pending);
 
-        let Verification {
-            amount: input_amount,
-            unit: input_unit,
-        } = input_verification;
-
-        ensure_cdk!(input_unit.is_some(), Error::UnsupportedUnit);
-
         let fee = self.get_proofs_fee(melt_request.inputs()).await?;
 
         let required_total = quote.amount + quote.fee_reserve + fee;
@@ -542,11 +554,6 @@ impl Mint {
             ));
         }
 
-        let mut proof_writer =
-            ProofWriter::new(self.localstore.clone(), self.pubsub_manager.clone());
-
-        proof_writer.add_proofs(tx, melt_request.inputs()).await?;
-
         let EnforceSigFlag { sig_flag, .. } = enforce_sig_flag(melt_request.inputs().clone());
 
         if sig_flag == SigFlag::SigAll {
@@ -619,6 +626,16 @@ impl Mint {
             }
         };
 
+        let inputs_fee = self.get_proofs_fee(melt_request.inputs()).await?;
+
+        tx.add_melt_request_and_blinded_messages(
+            melt_request.quote_id(),
+            melt_request.inputs_amount()?,
+            inputs_fee,
+            melt_request.outputs().as_ref().unwrap_or(&Vec::new()),
+        )
+        .await?;
+
         let settled_internally_amount = match self
             .handle_internal_melt_mint(&mut tx, &quote, melt_request)
             .await
@@ -811,14 +828,7 @@ impl Mint {
         // If we made it here the payment has been made.
         // We process the melt burning the inputs and returning change
         let res = match self
-            .process_melt_request(
-                tx,
-                proof_writer,
-                quote,
-                melt_request,
-                preimage,
-                amount_spent_quote_unit,
-            )
+            .process_melt_request(tx, proof_writer, quote, preimage, amount_spent_quote_unit)
             .await
         {
             Ok(response) => response,
@@ -854,14 +864,22 @@ impl Mint {
         mut tx: Box<dyn MintTransaction<'_, database::Error> + Send + Sync + '_>,
         mut proof_writer: ProofWriter,
         quote: MeltQuote,
-        melt_request: &MeltRequest<QuoteId>,
         payment_preimage: Option<String>,
         total_spent: Amount,
     ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
         #[cfg(feature = "prometheus")]
         METRICS.inc_in_flight_requests("process_melt_request");
 
-        let input_ys = melt_request.inputs().ys()?;
+        // Try to get input_ys from the stored melt request, fall back to original request if not found
+        let input_ys: Vec<_> = tx.get_proof_ys_by_quote_id(&quote.id).await?;
+
+        assert!(!input_ys.is_empty());
+
+        tracing::debug!(
+            "Updating {} proof states to Spent for quote {}",
+            input_ys.len(),
+            quote.id
+        );
 
         let update_proof_states_result = proof_writer
             .update_proofs_states(&mut tx, &input_ys, State::Spent)
@@ -872,22 +890,28 @@ impl Mint {
             self.record_melt_quote_failure("process_melt_request");
             return Err(update_proof_states_result.err().unwrap());
         }
+        tracing::debug!("Successfully updated proof states to Spent");
 
-        tx.update_melt_quote_state(
-            melt_request.quote(),
-            MeltQuoteState::Paid,
-            payment_preimage.clone(),
-        )
-        .await?;
+        tx.update_melt_quote_state(&quote.id, MeltQuoteState::Paid, payment_preimage.clone())
+            .await?;
 
         let mut change = None;
 
-        let inputs_amount = melt_request.inputs_amount()?;
+        let MeltRequestInfo {
+            inputs_amount,
+            inputs_fee,
+            change_outputs,
+        } = tx
+            .get_melt_request_and_blinded_messages(&quote.id)
+            .await?
+            .ok_or(Error::UnknownQuote)?;
 
         // Check if there is change to return
         if inputs_amount > total_spent {
             // Check if wallet provided change outputs
-            if let Some(outputs) = melt_request.outputs().clone() {
+            if !change_outputs.is_empty() {
+                let outputs = change_outputs;
+
                 let blinded_messages: Vec<PublicKey> =
                     outputs.iter().map(|b| b.blinded_secret).collect();
 
@@ -904,9 +928,7 @@ impl Mint {
                     return Err(Error::BlindedMessageAlreadySigned);
                 }
 
-                let fee = self.get_proofs_fee(melt_request.inputs()).await?;
-
-                let change_target = melt_request.inputs_amount()? - total_spent - fee;
+                let change_target = inputs_amount - total_spent - inputs_fee;
 
                 let mut amounts = change_target.split();
 

+ 3 - 2
crates/cdk/src/mint/proof_writer.rs

@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
 use cdk_common::database::{self, MintDatabase, MintTransaction};
-use cdk_common::{Error, Proofs, ProofsMethods, PublicKey, State};
+use cdk_common::{Error, Proofs, ProofsMethods, PublicKey, QuoteId, State};
 
 use super::subscription::PubSubManager;
 
@@ -49,6 +49,7 @@ impl ProofWriter {
         &mut self,
         tx: &mut Tx<'_, '_>,
         proofs: &Proofs,
+        quote_id: Option<QuoteId>,
     ) -> Result<Vec<PublicKey>, Error> {
         let proof_states = if let Some(proofs) = self.proof_original_states.as_mut() {
             proofs
@@ -56,7 +57,7 @@ impl ProofWriter {
             return Err(Error::Internal);
         };
 
-        if let Some(err) = tx.add_proofs(proofs.clone(), None).await.err() {
+        if let Some(err) = tx.add_proofs(proofs.clone(), quote_id).await.err() {
             return match err {
                 cdk_common::database::Error::Duplicate => Err(Error::TokenPending),
                 cdk_common::database::Error::AttemptUpdateSpentProof => {

+ 1 - 1
crates/cdk/src/mint/swap.rs

@@ -61,7 +61,7 @@ impl Mint {
         let mut proof_writer =
             ProofWriter::new(self.localstore.clone(), self.pubsub_manager.clone());
         let input_ys = match proof_writer
-            .add_proofs(&mut tx, swap_request.inputs())
+            .add_proofs(&mut tx, swap_request.inputs(), None)
             .await
         {
             Ok(ys) => ys,