use cdk_common::database::mint::Acquired;
use cdk_common::database::{self, DynMintDatabase};
use cdk_common::nuts::{BlindSignature, BlindedMessage, MeltQuoteState, State};
use cdk_common::{Amount, CurrencyUnit, Error, PublicKey, QuoteId};
use cdk_signatory::signatory::SignatoryKeySet;
use crate::mint::subscription::PubSubManager;
use crate::mint::MeltQuote;
use crate::Mint;
pub fn get_keyset_fee_and_amounts(
keysets: &arc_swap::ArcSwap<Vec<SignatoryKeySet>>,
outputs: &[BlindedMessage],
) -> cdk_common::amount::FeeAndAmounts {
keysets
.load()
.iter()
.filter_map(|keyset| {
if keyset.active && Some(keyset.id) == outputs.first().map(|x| x.keyset_id) {
Some((keyset.input_fee_ppk, keyset.amounts.clone()).into())
} else {
None
}
})
.next()
.unwrap_or_else(|| (0, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into())
}
pub async fn rollback_melt_quote(
db: &DynMintDatabase,
pubsub: &PubSubManager,
quote_id: &QuoteId,
input_ys: &[PublicKey],
blinded_secrets: &[PublicKey],
operation_id: &uuid::Uuid,
) -> Result<(), Error> {
if input_ys.is_empty() && blinded_secrets.is_empty() {
return Ok(());
}
tracing::info!(
"Rolling back melt quote {} ({} proofs, {} blinded messages, saga {})",
quote_id,
input_ys.len(),
blinded_secrets.len(),
operation_id
);
let mut tx = db.begin_transaction().await?;
let mut proofs_recovered = false;
if !input_ys.is_empty() {
match tx.remove_proofs(input_ys, Some(quote_id.clone())).await {
Ok(_) => {
proofs_recovered = true;
}
Err(database::Error::AttemptRemoveSpentProof) => {
tracing::warn!(
"Proofs already spent or missing during rollback for quote {}",
quote_id
);
}
Err(e) => return Err(e.into()),
}
}
if !blinded_secrets.is_empty() {
tx.delete_blinded_messages(blinded_secrets).await?;
}
let quote_option = if let Some(mut quote) = tx.get_melt_quote(quote_id).await? {
let previous_state = tx
.update_melt_quote_state(&mut quote, MeltQuoteState::Unpaid, None)
.await?;
if previous_state != MeltQuoteState::Pending {
tracing::warn!(
"Unexpected quote state during rollback: expected Pending, got {}",
previous_state
);
}
Some(quote)
} else {
None
};
tx.delete_melt_request(quote_id).await?;
if let Err(e) = tx.delete_saga(operation_id).await {
tracing::warn!(
"Failed to delete saga {} during rollback: {}",
operation_id,
e
);
}
tx.commit().await?;
if proofs_recovered {
for pk in input_ys.iter() {
pubsub.proof_state((*pk, State::Unspent));
}
}
if let Some(quote) = quote_option {
pubsub.melt_quote_status("e, None, None, MeltQuoteState::Unpaid);
}
tracing::info!(
"Successfully rolled back melt quote {} and deleted saga {}",
quote_id,
operation_id
);
Ok(())
}
pub async fn process_melt_change(
mint: &super::super::Mint,
db: &DynMintDatabase,
quote_id: &QuoteId,
inputs_amount: Amount<CurrencyUnit>,
total_spent: Amount<CurrencyUnit>,
inputs_fee: Amount<CurrencyUnit>,
change_outputs: Vec<BlindedMessage>,
) -> Result<
(
Option<Vec<BlindSignature>>,
Box<dyn database::MintTransaction<database::Error> + Send + Sync>,
),
Error,
> {
let needs_change = inputs_amount > total_spent;
if !needs_change || change_outputs.is_empty() {
let tx = db.begin_transaction().await?;
return Ok((None, tx));
}
let change_target: Amount = inputs_amount
.checked_sub(&total_spent)?
.checked_sub(&inputs_fee)?
.into();
let fee_and_amounts = get_keyset_fee_and_amounts(&mint.keysets, &change_outputs);
let mut amounts: Vec<Amount> = change_target.split(&fee_and_amounts)?;
if change_outputs.len() < amounts.len() {
tracing::debug!(
"Providing change requires {} blinded messages, but only {} provided",
amounts.len(),
change_outputs.len()
);
amounts.sort_by(|a, b| b.cmp(a));
}
let mut blinded_messages_to_sign = vec![];
for (amount, mut blinded_message) in amounts.iter().zip(change_outputs.iter().cloned()) {
blinded_message.amount = *amount;
blinded_messages_to_sign.push(blinded_message);
}
let change_sigs = mint.blind_sign(blinded_messages_to_sign.clone()).await?;
let mut tx = db.begin_transaction().await?;
let blinded_secrets: Vec<_> = blinded_messages_to_sign
.iter()
.map(|bm| bm.blinded_secret)
.collect();
tx.add_blind_signatures(&blinded_secrets, &change_sigs, Some(quote_id.clone()))
.await?;
Ok((Some(change_sigs), tx))
}
pub async fn load_melt_quotes_exclusively(
tx: &mut Box<dyn database::MintTransaction<database::Error> + Send + Sync>,
quote_id: &QuoteId,
) -> Result<Acquired<MeltQuote>, Error> {
let locked = tx
.lock_melt_quote_and_related(quote_id)
.await
.map_err(|e| match e {
database::Error::Locked => {
tracing::warn!("Quote {quote_id} or related quotes are locked by another process");
database::Error::Duplicate
}
e => e,
})?;
let quote = locked.target.ok_or(Error::UnknownQuote)?;
if let Some(conflict) = locked.all_related.iter().find(|locked_quote| {
locked_quote.id != quote.id
&& (locked_quote.state == MeltQuoteState::Pending
|| locked_quote.state == MeltQuoteState::Paid)
}) {
tracing::warn!(
"Cannot transition quote {} to Pending: another quote with lookup_id {:?} is already {:?}",
quote.id,
quote.request_lookup_id,
conflict.state,
);
return Err(match conflict.state {
MeltQuoteState::Pending => Error::PendingQuote,
MeltQuoteState::Paid => Error::RequestAlreadyPaid,
_ => unreachable!("Only Pending/Paid states reach this branch"),
});
}
Ok(quote)
}
#[allow(clippy::too_many_arguments)]
pub async fn finalize_melt_core(
tx: &mut Box<dyn database::MintTransaction<database::Error> + Send + Sync>,
pubsub: &PubSubManager,
quote: &mut Acquired<MeltQuote>,
input_ys: &[PublicKey],
inputs_amount: Amount<CurrencyUnit>,
inputs_fee: Amount<CurrencyUnit>,
total_spent: Amount<CurrencyUnit>,
payment_preimage: Option<String>,
payment_lookup_id: &cdk_common::payment::PaymentIdentifier,
) -> Result<(), Error> {
if quote.amount() > total_spent {
tracing::error!(
"Payment amount {} is less than quote amount {} for quote {}",
total_spent,
quote.amount(),
quote.id
);
return Err(Error::IncorrectQuoteAmount);
}
let net_inputs = inputs_amount.checked_sub(&inputs_fee)?;
let total_spent = total_spent.convert_to(net_inputs.unit())?;
tracing::debug!(
"Melt validation for quote {}: inputs_amount={}, inputs_fee={}, net_inputs={}, total_spent={}, quote_amount={}, fee_reserve={}",
quote.id,
inputs_amount.display_with_unit(),
inputs_fee.display_with_unit(),
net_inputs.display_with_unit(),
total_spent.display_with_unit(),
quote.amount().display_with_unit(),
quote.fee_reserve().display_with_unit(),
);
debug_assert!(
net_inputs >= total_spent,
"Over paid melt quote {}: net_inputs ({}) < total_spent ({}). Payment already complete, finalizing with no change.",
quote.id,
net_inputs.display_with_unit(),
total_spent.display_with_unit(),
);
if net_inputs < total_spent {
tracing::error!(
"Over paid melt quote {}: net_inputs ({}) < total_spent ({}). Payment already complete, finalizing with no change.",
quote.id,
net_inputs.display_with_unit(),
total_spent.display_with_unit(),
);
}
tx.update_melt_quote_state(quote, MeltQuoteState::Paid, payment_preimage.clone())
.await?;
quote.state = MeltQuoteState::Paid;
if quote.request_lookup_id.as_ref() != Some(payment_lookup_id) {
tracing::info!(
"Payment lookup id changed post payment from {:?} to {}",
"e.request_lookup_id,
payment_lookup_id
);
tx.update_melt_quote_request_lookup_id(quote, payment_lookup_id)
.await?;
}
let mut proofs = tx.get_proofs(input_ys).await?;
Mint::update_proofs_state(tx, &mut proofs, State::Spent).await?;
for pk in input_ys.iter() {
pubsub.proof_state((*pk, State::Spent));
}
Ok(())
}
pub async fn finalize_melt_quote(
mint: &super::super::Mint,
db: &DynMintDatabase,
pubsub: &PubSubManager,
quote: &MeltQuote,
total_spent: Amount<CurrencyUnit>,
payment_preimage: Option<String>,
payment_lookup_id: &cdk_common::payment::PaymentIdentifier,
) -> Result<Option<Vec<BlindSignature>>, Error> {
tracing::info!("Finalizing melt quote {}", quote.id);
let mut tx = db.begin_transaction().await?;
let mut locked_quote = load_melt_quotes_exclusively(&mut tx, "e.id).await?;
let melt_request_info = match tx.get_melt_request_and_blinded_messages("e.id).await? {
Some(info) => info,
None => {
tracing::warn!(
"No melt request found for quote {} - may have been completed already",
quote.id
);
tx.rollback().await?;
return Ok(None);
}
};
let input_ys = tx.get_proof_ys_by_quote_id("e.id).await?;
if input_ys.is_empty() {
tracing::warn!(
"No input proofs found for quote {} - may have been completed already",
quote.id
);
tx.rollback().await?;
return Ok(None);
}
if locked_quote.state == MeltQuoteState::Paid {
tracing::info!(
"Melt quote {} already Paid — TX1 previously committed, skipping to change/cleanup",
quote.id
);
tx.commit().await?;
} else {
finalize_melt_core(
&mut tx,
pubsub,
&mut locked_quote,
&input_ys,
melt_request_info.inputs_amount.clone(),
melt_request_info.inputs_fee.clone(),
total_spent.clone(),
payment_preimage.clone(),
payment_lookup_id,
)
.await?;
tx.commit().await?;
}
let existing_sigs = db.get_blind_signatures_for_quote("e.id).await?;
let needs_change = melt_request_info.inputs_amount > total_spent;
let (change_sigs, mut tx) = if needs_change && !existing_sigs.is_empty() {
tracing::info!(
"Change signatures already exist for quote {} ({} sigs), skipping re-sign",
quote.id,
existing_sigs.len()
);
let tx = db.begin_transaction().await?;
(Some(existing_sigs), tx)
} else {
process_melt_change(
mint,
db,
"e.id,
melt_request_info.inputs_amount,
total_spent,
melt_request_info.inputs_fee,
melt_request_info.change_outputs.clone(),
)
.await?
};
tx.delete_melt_request("e.id).await?;
tx.commit().await?;
pubsub.melt_quote_status(
&locked_quote,
payment_preimage,
change_sigs.clone(),
MeltQuoteState::Paid,
);
tracing::info!("Successfully finalized melt quote {}", quote.id);
Ok(change_sigs)
}