diff --git a/crates/cdk-common/src/database/mint/mod.rs b/crates/cdk-common/src/database/mint/mod.rs index 3d68abc5..02a26aee 100644 --- a/crates/cdk-common/src/database/mint/mod.rs +++ b/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, +} /// KeysDatabaseWriter #[async_trait] @@ -123,6 +135,24 @@ pub trait QuotesTransaction<'a> { /// Mint Quotes Database Error type Err: Into + From; + /// 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, 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, ) -> Result<(), Self::Err>; + + /// Get ys by quote id + async fn get_proof_ys_by_quote_id( + &self, + quote_id: &QuoteId, + ) -> Result, Self::Err>; } /// Mint Proof Database trait diff --git a/crates/cdk-postgres/start_db_for_test.sh b/crates/cdk-postgres/start_db_for_test.sh old mode 100644 new mode 100755 diff --git a/crates/cdk-sql-common/src/mint/migrations.rs b/crates/cdk-sql-common/src/mint/migrations.rs index 1db88ede..9609719e 100644 --- a/crates/cdk-sql-common/src/mint/migrations.rs +++ b/crates/cdk-sql-common/src/mint/migrations.rs @@ -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"#)), ]; diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20250902140000_add_melt_request_and_blinded_messages.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20250902140000_add_melt_request_and_blinded_messages.sql new file mode 100644 index 00000000..af232439 --- /dev/null +++ b/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); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20250902140000_add_melt_request_and_blinded_messages.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250902140000_add_melt_request_and_blinded_messages.sql new file mode 100644 index 00000000..9c8b5632 --- /dev/null +++ b/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); diff --git a/crates/cdk-sql-common/src/mint/mod.rs b/crates/cdk-sql-common/src/mint/mod.rs index e86b354d..19b3042c 100644 --- a/crates/cdk-sql-common/src/mint/mod.rs +++ b/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, 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::, _>>()? + .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, 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, Error> = blinded_messages_rows + .into_iter() + .map(|row| -> Result { + 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)) } diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs index aba1ee48..40baa430 100644 --- a/crates/cdk/src/mint/melt.rs +++ b/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, ) -> 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("e, 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, "e, 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 + Send + Sync + '_>, mut proof_writer: ProofWriter, quote: MeltQuote, - melt_request: &MeltRequest, payment_preimage: Option, total_spent: Amount, ) -> Result, 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("e.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("e.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("e.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 = 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(); diff --git a/crates/cdk/src/mint/proof_writer.rs b/crates/cdk/src/mint/proof_writer.rs index b21bd3a9..a7d184b7 100644 --- a/crates/cdk/src/mint/proof_writer.rs +++ b/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, ) -> Result, 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 => { diff --git a/crates/cdk/src/mint/swap.rs b/crates/cdk/src/mint/swap.rs index 0e14fa32..0cab12a3 100644 --- a/crates/cdk/src/mint/swap.rs +++ b/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,