use std::collections::HashMap;
use cdk_common::amount::SplitTarget;
use cdk_common::dhke::construct_proofs;
use cdk_common::wallet::{
MeltOperationData, MeltQuote, MeltSagaState, OperationData, ProofInfo, Transaction,
TransactionDirection, WalletSaga, WalletSagaState,
};
use cdk_common::{MeltQuoteState, PaymentMethod};
use tracing::instrument;
use uuid::Uuid;
use self::compensation::{ReleaseMeltQuote, RevertProofReservation};
use self::state::{Finalized, Initial, MeltRequested, PaymentPending, Prepared};
use super::MeltConfirmOptions;
use crate::nuts::nut00::{KnownMethod, ProofsMethods};
use crate::nuts::{MeltRequest, PreMintSecrets, Proofs, State};
use crate::util::unix_time;
use crate::wallet::blind_signature::{
validate_mint_response_signatures, SignatureAmountValidation,
};
use crate::wallet::keysets::KeysetFilter;
use crate::wallet::saga::{add_compensation, new_compensations, Compensations};
use crate::{ensure_cdk, Amount, Error, Wallet};
pub(crate) mod compensation;
pub(crate) mod resume;
pub(crate) mod state;
pub enum MeltSagaResult<'a> {
Finalized(MeltSaga<'a, Finalized>),
Pending(Box<MeltSaga<'a, PaymentPending>>),
}
pub(crate) struct MeltSaga<'a, S> {
pub(crate) wallet: &'a Wallet,
pub(crate) compensations: Compensations,
pub(crate) state_data: S,
}
#[allow(clippy::too_many_arguments)]
async fn finalize_melt_common<'a>(
wallet: &'a Wallet,
compensations: Compensations,
operation_id: Uuid,
quote_info: &MeltQuote,
final_proofs: &Proofs,
premint_secrets: &PreMintSecrets,
state: MeltQuoteState,
payment_proof: Option<String>,
change: Option<Vec<crate::nuts::BlindSignature>>,
metadata: HashMap<String, String>,
) -> Result<MeltSaga<'a, Finalized>, Error> {
let active_keyset_id = wallet.fetch_active_keyset().await?.id;
let active_keys = wallet.load_keyset_keys(active_keyset_id).await?;
let change_proofs = match change {
Some(change) => {
let num_change_proof = change.len();
let num_change_proof = match (
premint_secrets.len() < num_change_proof,
premint_secrets.secrets().len() < num_change_proof,
) {
(true, _) | (_, true) => {
tracing::error!("Mismatch in change promises to change");
premint_secrets.len()
}
_ => num_change_proof,
};
validate_mint_response_signatures(
wallet,
&change,
premint_secrets.secrets[..num_change_proof]
.iter()
.map(|p| &p.blinded_message),
SignatureAmountValidation::AllowZeroAmountPlaceholder,
)
.await?;
Some(construct_proofs(
change,
premint_secrets.rs()[..num_change_proof].to_vec(),
premint_secrets.secrets()[..num_change_proof].to_vec(),
&active_keys,
)?)
}
None => None,
};
let proofs_total = final_proofs.total_amount()?;
let change_total = change_proofs
.as_ref()
.map(|p| p.total_amount())
.transpose()?
.unwrap_or(Amount::ZERO);
let fee = proofs_total
.checked_sub(quote_info.amount)
.and_then(|amount| amount.checked_sub(change_total))
.ok_or(Error::AmountOverflow)?;
let mut updated_quote = quote_info.clone();
updated_quote.state = state;
wallet.localstore.add_melt_quote(updated_quote).await?;
let change_proof_infos = match change_proofs.clone() {
Some(change_proofs) => change_proofs
.into_iter()
.map(|proof| {
ProofInfo::new(
proof,
wallet.mint_url.clone(),
State::Unspent,
quote_info.unit.clone(),
)
})
.collect::<Result<Vec<ProofInfo>, _>>()?,
None => Vec::new(),
};
wallet
.localstore
.update_proofs(change_proof_infos, vec![])
.await?;
let spent_ys = final_proofs.ys()?;
wallet
.localstore
.update_proofs_state(spent_ys, State::Spent)
.await?;
wallet
.localstore
.add_transaction(Transaction {
mint_url: wallet.mint_url.clone(),
direction: TransactionDirection::Outgoing,
amount: quote_info.amount,
fee,
unit: wallet.unit.clone(),
ys: final_proofs.ys()?,
timestamp: unix_time(),
memo: None,
metadata,
quote_id: Some(quote_info.id.clone()),
payment_request: Some(quote_info.request.clone()),
payment_proof: payment_proof.clone(),
payment_method: Some(quote_info.payment_method.clone()),
saga_id: Some(operation_id),
})
.await?;
if let Err(e) = wallet.localstore.release_melt_quote(&operation_id).await {
tracing::warn!(
"Failed to release melt quote for operation {}: {}",
operation_id,
e
);
}
if let Err(e) = wallet.localstore.delete_saga(&operation_id).await {
tracing::warn!(
"Failed to delete melt saga {}: {}. Will be cleaned up on recovery.",
operation_id,
e
);
}
Ok(MeltSaga {
wallet,
compensations,
state_data: Finalized {
quote_id: quote_info.id.clone(),
state,
amount: quote_info.amount,
fee,
payment_proof,
change: change_proofs,
},
})
}
impl<'a> MeltSaga<'a, Initial> {
pub fn new(wallet: &'a Wallet) -> Self {
let operation_id = uuid::Uuid::now_v7();
Self {
wallet,
compensations: new_compensations(),
state_data: Initial { operation_id },
}
}
async fn initialize_melt(&mut self, quote_id: &str) -> Result<MeltQuote, Error> {
let quote_info = self
.wallet
.localstore
.get_melt_quote(quote_id)
.await?
.ok_or(Error::UnknownQuote)?;
ensure_cdk!(
quote_info.expiry.gt(&unix_time()),
Error::ExpiredQuote(quote_info.expiry, unix_time())
);
self.wallet
.localstore
.reserve_melt_quote(quote_id, &self.state_data.operation_id)
.await?;
add_compensation(
&mut self.compensations,
Box::new(ReleaseMeltQuote {
localstore: self.wallet.localstore.clone(),
operation_id: self.state_data.operation_id,
}),
)
.await;
Ok(quote_info)
}
#[instrument(skip_all)]
pub async fn prepare(
mut self,
quote_id: &str,
_metadata: HashMap<String, String>,
) -> Result<MeltSaga<'a, Prepared>, Error> {
tracing::info!(
"Preparing melt for quote {} with operation {}",
quote_id,
self.state_data.operation_id
);
let quote_info = self.initialize_melt(quote_id).await?;
let inputs_needed_amount = quote_info.amount + quote_info.fee_reserve;
let active_keyset_ids = self
.wallet
.get_mint_keysets(KeysetFilter::Active)
.await?
.into_iter()
.map(|k| k.id)
.collect();
let keyset_fees_and_amounts = self.wallet.get_keyset_fees_and_amounts().await?;
let available_proofs = self.wallet.get_unspent_proofs().await?;
let exact_input_proofs = Wallet::select_proofs(
inputs_needed_amount,
available_proofs.clone(),
&active_keyset_ids,
&keyset_fees_and_amounts,
true,
)?;
let proofs_total = exact_input_proofs.total_amount()?;
if proofs_total == inputs_needed_amount {
let proof_ys = exact_input_proofs.ys()?;
let operation_id = self.state_data.operation_id;
self.wallet
.localstore
.reserve_proofs(proof_ys.clone(), &operation_id)
.await?;
let saga = WalletSaga::new(
operation_id,
WalletSagaState::Melt(MeltSagaState::ProofsReserved),
quote_info.amount,
self.wallet.mint_url.clone(),
self.wallet.unit.clone(),
OperationData::Melt(MeltOperationData {
quote_id: quote_id.to_string(),
amount: quote_info.amount,
fee_reserve: quote_info.fee_reserve,
counter_start: None,
counter_end: None,
change_amount: None,
change_blinded_messages: None,
}),
);
self.wallet.localstore.add_saga(saga.clone()).await?;
add_compensation(
&mut self.compensations,
Box::new(RevertProofReservation {
localstore: self.wallet.localstore.clone(),
proof_ys,
saga_id: operation_id,
}),
)
.await;
let input_fee = self.wallet.get_proofs_fee(&exact_input_proofs).await?.total;
return Ok(MeltSaga {
wallet: self.wallet,
compensations: self.compensations,
state_data: Prepared {
operation_id: self.state_data.operation_id,
quote: quote_info,
proofs: exact_input_proofs,
proofs_to_swap: Proofs::new(),
swap_fee: Amount::ZERO,
input_fee,
input_fee_without_swap: input_fee,
saga,
},
});
}
let active_keyset_id = self.wallet.get_active_keyset().await?.id;
let fee_and_amounts = self
.wallet
.get_keyset_fees_and_amounts_by_id(active_keyset_id)
.await?;
let estimated_output_count = inputs_needed_amount.split(&fee_and_amounts)?.len();
let estimated_melt_fee = self
.wallet
.get_keyset_count_fee(&active_keyset_id, estimated_output_count as u64)
.await?;
let selection_amount = inputs_needed_amount + estimated_melt_fee;
let input_proofs = Wallet::select_proofs(
selection_amount,
available_proofs,
&active_keyset_ids,
&keyset_fees_and_amounts,
true,
)?;
let input_fee = estimated_melt_fee;
let proofs_to_send = Proofs::new();
let proofs_to_swap = input_proofs;
let swap_fee = self.wallet.get_proofs_fee(&proofs_to_swap).await?.total;
let proof_ys = proofs_to_swap.ys()?;
let operation_id = self.state_data.operation_id;
if !proof_ys.is_empty() {
self.wallet
.localstore
.reserve_proofs(proof_ys.clone(), &operation_id)
.await?;
}
let saga = WalletSaga::new(
operation_id,
WalletSagaState::Melt(MeltSagaState::ProofsReserved),
quote_info.amount,
self.wallet.mint_url.clone(),
self.wallet.unit.clone(),
OperationData::Melt(MeltOperationData {
quote_id: quote_id.to_string(),
amount: quote_info.amount,
fee_reserve: quote_info.fee_reserve,
counter_start: None,
counter_end: None,
change_amount: None,
change_blinded_messages: None, }),
);
self.wallet.localstore.add_saga(saga.clone()).await?;
add_compensation(
&mut self.compensations,
Box::new(RevertProofReservation {
localstore: self.wallet.localstore.clone(),
proof_ys,
saga_id: operation_id,
}),
)
.await;
let input_fee_without_swap = swap_fee;
Ok(MeltSaga {
wallet: self.wallet,
compensations: self.compensations,
state_data: Prepared {
operation_id: self.state_data.operation_id,
quote: quote_info,
proofs: proofs_to_send,
proofs_to_swap,
swap_fee,
input_fee,
input_fee_without_swap,
saga,
},
})
}
#[instrument(skip_all)]
pub async fn prepare_with_proofs(
mut self,
quote_id: &str,
proofs: Proofs,
_metadata: HashMap<String, String>,
) -> Result<MeltSaga<'a, Prepared>, Error> {
tracing::info!(
"Preparing melt with specific proofs for quote {} with operation {}",
quote_id,
self.state_data.operation_id
);
let quote_info = self.initialize_melt(quote_id).await?;
let proofs_total = proofs.total_amount()?;
let inputs_needed = quote_info.amount + quote_info.fee_reserve;
if proofs_total < inputs_needed {
return Err(Error::InsufficientFunds);
}
let operation_id = self.state_data.operation_id;
let proof_ys = proofs.ys()?;
let proofs_info = proofs
.clone()
.into_iter()
.map(|p| {
ProofInfo::new_with_operations(
p,
self.wallet.mint_url.clone(),
State::Reserved,
self.wallet.unit.clone(),
Some(operation_id),
None,
)
})
.collect::<Result<Vec<ProofInfo>, _>>()?;
self.wallet
.localstore
.update_proofs(proofs_info, vec![])
.await?;
let saga = WalletSaga::new(
operation_id,
WalletSagaState::Melt(MeltSagaState::ProofsReserved),
quote_info.amount,
self.wallet.mint_url.clone(),
self.wallet.unit.clone(),
OperationData::Melt(MeltOperationData {
quote_id: quote_id.to_string(),
amount: quote_info.amount,
fee_reserve: quote_info.fee_reserve,
counter_start: None,
counter_end: None,
change_amount: None,
change_blinded_messages: None,
}),
);
self.wallet.localstore.add_saga(saga.clone()).await?;
add_compensation(
&mut self.compensations,
Box::new(RevertProofReservation {
localstore: self.wallet.localstore.clone(),
proof_ys,
saga_id: operation_id,
}),
)
.await;
let input_fee = self.wallet.get_proofs_fee(&proofs).await?.total;
Ok(MeltSaga {
wallet: self.wallet,
compensations: self.compensations,
state_data: Prepared {
operation_id: self.state_data.operation_id,
quote: quote_info,
proofs,
proofs_to_swap: Proofs::new(),
swap_fee: Amount::ZERO,
input_fee,
input_fee_without_swap: input_fee,
saga,
},
})
}
}
impl<'a> MeltSaga<'a, Prepared> {
#[allow(clippy::too_many_arguments)]
pub fn from_prepared(
wallet: &'a Wallet,
operation_id: uuid::Uuid,
quote: MeltQuote,
proofs: Proofs,
proofs_to_swap: Proofs,
input_fee: Amount,
input_fee_without_swap: Amount,
saga: WalletSaga,
) -> Self {
Self {
wallet,
compensations: new_compensations(),
state_data: Prepared {
operation_id,
quote,
proofs,
proofs_to_swap,
swap_fee: Amount::ZERO,
input_fee,
input_fee_without_swap,
saga,
},
}
}
pub fn operation_id(&self) -> uuid::Uuid {
self.state_data.operation_id
}
pub fn quote(&self) -> &MeltQuote {
&self.state_data.quote
}
pub fn proofs(&self) -> &Proofs {
&self.state_data.proofs
}
pub fn proofs_to_swap(&self) -> &Proofs {
&self.state_data.proofs_to_swap
}
pub fn swap_fee(&self) -> Amount {
self.state_data.swap_fee
}
pub fn input_fee(&self) -> Amount {
self.state_data.input_fee
}
pub fn input_fee_without_swap(&self) -> Amount {
self.state_data.input_fee_without_swap
}
#[instrument(skip_all)]
pub async fn request_melt_with_options(
mut self,
options: MeltConfirmOptions,
) -> Result<MeltSaga<'a, MeltRequested>, Error> {
let operation_id = self.state_data.operation_id;
let quote_info = self.state_data.quote.clone();
let input_fee = self.state_data.input_fee;
tracing::info!(
"Building melt request for quote {} with operation {} (skip_swap: {})",
quote_info.id,
operation_id,
options.skip_swap
);
let active_keyset_id = self.wallet.fetch_active_keyset().await?.id;
let mut final_proofs = self.state_data.proofs.clone();
if !self.state_data.proofs_to_swap.is_empty() {
if options.skip_swap {
tracing::debug!(
"Skipping swap, using {} proofs directly (total: {})",
self.state_data.proofs_to_swap.len(),
self.state_data.proofs_to_swap.total_amount()?,
);
final_proofs.extend(self.state_data.proofs_to_swap.clone());
} else {
let target_swap_amount = quote_info.amount + quote_info.fee_reserve + input_fee;
tracing::debug!(
"Swapping {} proofs (total: {}) for target amount {}",
self.state_data.proofs_to_swap.len(),
self.state_data.proofs_to_swap.total_amount()?,
target_swap_amount
);
if let Some(swapped) = self
.wallet
.swap_no_reserve(
Some(target_swap_amount),
SplitTarget::None,
self.state_data.proofs_to_swap.clone(),
None,
false,
false,
)
.await?
{
final_proofs.extend(swapped);
}
}
}
let actual_input_fee = self.wallet.get_proofs_fee(&final_proofs).await?.total;
let inputs_needed_amount = quote_info.amount + quote_info.fee_reserve + actual_input_fee;
let proofs_total = final_proofs.total_amount()?;
if proofs_total < inputs_needed_amount {
self.compensate().await;
return Err(Error::InsufficientFunds);
}
let proofs_info = final_proofs
.clone()
.into_iter()
.map(|p| {
ProofInfo::new_with_operations(
p,
self.wallet.mint_url.clone(),
State::Pending,
self.wallet.unit.clone(),
Some(operation_id),
None,
)
})
.collect::<Result<Vec<ProofInfo>, _>>()?;
self.wallet
.localstore
.update_proofs(proofs_info, vec![])
.await?;
add_compensation(
&mut self.compensations,
Box::new(RevertProofReservation {
localstore: self.wallet.localstore.clone(),
proof_ys: final_proofs.ys()?,
saga_id: operation_id,
}),
)
.await;
let change_amount = proofs_total - quote_info.amount - actual_input_fee;
let premint_secrets = if change_amount <= Amount::ZERO {
PreMintSecrets::new(active_keyset_id)
} else {
let num_secrets =
((u64::from(change_amount) as f64).log2().ceil() as u64).max(1) as u32;
let new_counter = self
.wallet
.localstore
.increment_keyset_counter(&active_keyset_id, num_secrets)
.await?;
let count = new_counter - num_secrets;
PreMintSecrets::from_seed_blank(
active_keyset_id,
count,
&self.wallet.seed,
change_amount,
)?
};
let counter_end = self
.wallet
.localstore
.increment_keyset_counter(&active_keyset_id, 0)
.await?;
let counter_start = counter_end.saturating_sub(premint_secrets.secrets.len() as u32);
let change_blinded_messages = if change_amount > Amount::ZERO {
Some(premint_secrets.blinded_messages())
} else {
None
};
let mut saga = self.state_data.saga.clone();
saga.update_state(WalletSagaState::Melt(MeltSagaState::MeltRequested));
if let OperationData::Melt(ref mut data) = saga.data {
data.counter_start = Some(counter_start);
data.counter_end = Some(counter_end);
data.change_amount = if change_amount > Amount::ZERO {
Some(change_amount)
} else {
None
};
data.change_blinded_messages = change_blinded_messages.clone();
}
if !self.wallet.localstore.update_saga(saga.clone()).await? {
return Err(Error::ConcurrentUpdate);
}
Ok(MeltSaga {
wallet: self.wallet,
compensations: self.compensations,
state_data: MeltRequested {
operation_id,
quote: quote_info,
final_proofs,
premint_secrets,
},
})
}
async fn compensate(self) {
let mut compensations = self.compensations;
while let Some(action) = compensations.pop_front() {
if let Err(e) = action.execute().await {
tracing::warn!("Compensation {} failed: {}", action.name(), e);
}
}
}
pub async fn cancel(self) -> Result<(), Error> {
self.compensate().await;
Ok(())
}
}
impl std::fmt::Debug for MeltSaga<'_, Prepared> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MeltSaga<Prepared>")
.field("operation_id", &self.state_data.operation_id)
.field("quote_id", &self.state_data.quote.id)
.field("amount", &self.state_data.quote.amount)
.field(
"proofs",
&self
.state_data
.proofs
.iter()
.map(|p| p.amount)
.collect::<Vec<_>>(),
)
.field(
"proofs_to_swap",
&self
.state_data
.proofs_to_swap
.iter()
.map(|p| p.amount)
.collect::<Vec<_>>(),
)
.field("swap_fee", &self.state_data.swap_fee)
.field("input_fee", &self.state_data.input_fee)
.finish()
}
}
impl std::fmt::Debug for MeltSaga<'_, PaymentPending> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MeltSaga<PaymentPending>")
.field("operation_id", &self.state_data.operation_id)
.field("quote_id", &self.state_data.quote.id)
.field("amount", &self.state_data.quote.amount)
.finish()
}
}
impl<'a> MeltSaga<'a, MeltRequested> {
#[instrument(skip_all)]
pub async fn execute_async(
self,
metadata: HashMap<String, String>,
) -> Result<MeltSagaResult<'a>, Error> {
let operation_id = self.state_data.operation_id;
let quote_info = &self.state_data.quote;
tracing::info!(
"Executing async melt request for quote {} with operation {}",
quote_info.id,
operation_id
);
let request = MeltRequest::new(
quote_info.id.clone(),
self.state_data.final_proofs.clone(),
if self.state_data.premint_secrets.is_empty() {
None
} else {
Some(self.state_data.premint_secrets.blinded_messages())
},
)
.prefer_async(true);
let request = if quote_info.payment_method == PaymentMethod::Known(KnownMethod::Onchain) {
request.fee_index(quote_info.fee_index.ok_or(Error::InvalidPaymentRequest)?)
} else {
request
};
let melt_result = self
.wallet
.client
.post_melt("e_info.payment_method, request)
.await;
let melt_response = match melt_result {
Ok(response) => response,
Err(error) => {
if matches!(error, Error::RequestAlreadyPaid) {
tracing::info!("Invoice already paid by another wallet - releasing proofs");
self.handle_failure().await;
return Err(error);
}
tracing::warn!(
"Melt request failed with error: {}. Checking quote status...",
error
);
match self.wallet.internal_check_melt_status("e_info.id).await {
Ok(response) => match response.state() {
MeltQuoteState::Failed
| MeltQuoteState::Unknown
| MeltQuoteState::Unpaid => {
if response.payment_proof().is_some() {
tracing::warn!(
"Quote {} status is {:?} but mint reports a \
payment proof; keeping proofs pending to \
avoid loss",
quote_info.id,
response.state()
);
self.handle_pending().await;
return Ok(MeltSagaResult::Pending(Box::new(MeltSaga {
wallet: self.wallet,
compensations: self.compensations,
state_data: PaymentPending {
operation_id: self.state_data.operation_id,
quote: self.state_data.quote,
final_proofs: self.state_data.final_proofs.clone(),
premint_secrets: self.state_data.premint_secrets.clone(),
},
})));
}
tracing::info!(
"Quote {} status is {:?} - releasing proofs",
quote_info.id,
response.state()
);
self.handle_failure().await;
return Err(Error::PaymentFailed);
}
MeltQuoteState::Paid => {
tracing::info!(
"Quote {} confirmed paid - finalizing with change",
quote_info.id
);
let standard_response = response.into_standard()?;
let finalized = finalize_melt_common(
self.wallet,
self.compensations,
self.state_data.operation_id,
&self.state_data.quote,
&self.state_data.final_proofs,
&self.state_data.premint_secrets,
standard_response.state,
standard_response.payment_preimage,
standard_response.change,
metadata,
)
.await?;
return Ok(MeltSagaResult::Finalized(finalized));
}
MeltQuoteState::Pending => {
tracing::info!(
"Quote {} status is Pending - keeping proofs pending",
quote_info.id
);
self.handle_pending().await;
return Ok(MeltSagaResult::Pending(Box::new(MeltSaga {
wallet: self.wallet,
compensations: self.compensations,
state_data: PaymentPending {
operation_id: self.state_data.operation_id,
quote: self.state_data.quote,
final_proofs: self.state_data.final_proofs.clone(),
premint_secrets: self.state_data.premint_secrets.clone(),
},
})));
}
},
Err(check_err) => {
tracing::warn!(
"Failed to check quote {} status: {}. Keeping proofs pending.",
quote_info.id,
check_err
);
self.handle_pending().await;
return Ok(MeltSagaResult::Pending(Box::new(MeltSaga {
wallet: self.wallet,
compensations: self.compensations,
state_data: PaymentPending {
operation_id: self.state_data.operation_id,
quote: self.state_data.quote,
final_proofs: self.state_data.final_proofs.clone(),
premint_secrets: self.state_data.premint_secrets.clone(),
},
})));
}
}
}
};
match melt_response.state() {
MeltQuoteState::Paid => {
let finalized = finalize_melt_common(
self.wallet,
self.compensations,
self.state_data.operation_id,
&self.state_data.quote,
&self.state_data.final_proofs,
&self.state_data.premint_secrets,
melt_response.state(),
melt_response.payment_proof().map(|s| s.to_string()),
melt_response.change().cloned(),
metadata,
)
.await?;
Ok(MeltSagaResult::Finalized(finalized))
}
MeltQuoteState::Pending => {
self.handle_pending().await;
Ok(MeltSagaResult::Pending(Box::new(MeltSaga {
wallet: self.wallet,
compensations: self.compensations,
state_data: PaymentPending {
operation_id: self.state_data.operation_id,
quote: self.state_data.quote,
final_proofs: self.state_data.final_proofs.clone(),
premint_secrets: self.state_data.premint_secrets.clone(),
},
})))
}
MeltQuoteState::Failed => {
if melt_response.payment_proof().is_some() {
tracing::warn!(
"Melt quote {} reported Failed state but mint holds a \
payment proof; keeping proofs pending to avoid loss",
quote_info.id
);
self.handle_pending().await;
return Ok(MeltSagaResult::Pending(Box::new(MeltSaga {
wallet: self.wallet,
compensations: self.compensations,
state_data: PaymentPending {
operation_id: self.state_data.operation_id,
quote: self.state_data.quote,
final_proofs: self.state_data.final_proofs.clone(),
premint_secrets: self.state_data.premint_secrets.clone(),
},
})));
}
self.handle_failure().await;
Err(Error::PaymentFailed)
}
MeltQuoteState::Unpaid => {
if melt_response.payment_proof().is_some() {
tracing::warn!(
"Melt quote {} reported Unpaid state but mint holds a \
payment proof; keeping proofs pending to avoid loss",
quote_info.id
);
self.handle_pending().await;
return Ok(MeltSagaResult::Pending(Box::new(MeltSaga {
wallet: self.wallet,
compensations: self.compensations,
state_data: PaymentPending {
operation_id: self.state_data.operation_id,
quote: self.state_data.quote,
final_proofs: self.state_data.final_proofs.clone(),
premint_secrets: self.state_data.premint_secrets.clone(),
},
})));
}
tracing::warn!(
"Melt quote {} returned Unpaid state - releasing proofs",
quote_info.id
);
self.handle_failure().await;
Err(Error::PaymentFailed)
}
MeltQuoteState::Unknown => {
tracing::warn!(
"Melt quote {} returned Unknown state - keeping proofs pending",
quote_info.id
);
self.handle_pending().await;
Ok(MeltSagaResult::Pending(Box::new(MeltSaga {
wallet: self.wallet,
compensations: self.compensations,
state_data: PaymentPending {
operation_id: self.state_data.operation_id,
quote: self.state_data.quote,
final_proofs: self.state_data.final_proofs.clone(),
premint_secrets: self.state_data.premint_secrets.clone(),
},
})))
}
}
}
async fn handle_pending(&self) {
let quote_info = &self.state_data.quote;
tracing::info!(
"Melt quote {} is pending - proofs kept in pending state",
quote_info.id
);
}
async fn handle_failure(&self) {
let operation_id = self.state_data.operation_id;
let final_proofs = &self.state_data.final_proofs;
if let Ok(all_ys) = final_proofs.ys() {
let _ = self
.wallet
.localstore
.update_proofs_state(all_ys, State::Unspent)
.await;
}
let _ = self
.wallet
.localstore
.release_melt_quote(&operation_id)
.await;
let _ = self.wallet.localstore.delete_saga(&operation_id).await;
}
}
impl<'a> MeltSaga<'a, PaymentPending> {
pub fn quote(&self) -> &MeltQuote {
&self.state_data.quote
}
pub async fn finalize(
self,
state: MeltQuoteState,
payment_proof: Option<String>,
change: Option<Vec<crate::nuts::BlindSignature>>,
metadata: HashMap<String, String>,
) -> Result<MeltSaga<'a, Finalized>, Error> {
finalize_melt_common(
self.wallet,
self.compensations,
self.state_data.operation_id,
&self.state_data.quote,
&self.state_data.final_proofs,
&self.state_data.premint_secrets,
state,
payment_proof,
change,
metadata,
)
.await
}
pub async fn handle_failure(&self) {
let operation_id = self.state_data.operation_id;
let final_proofs = &self.state_data.final_proofs;
tracing::info!(
"Handling failure for melt operation {}. Restoring {} proofs. Total amount: {}",
operation_id,
final_proofs.len(),
final_proofs.total_amount().unwrap_or(Amount::ZERO)
);
if let Ok(all_ys) = final_proofs.ys() {
if let Err(e) = self
.wallet
.localstore
.update_proofs_state(all_ys, State::Unspent)
.await
{
tracing::error!("Failed to restore proofs for failed melt: {}", e);
} else {
tracing::info!("Successfully restored proofs to Unspent");
}
}
let _ = self
.wallet
.localstore
.release_melt_quote(&operation_id)
.await;
let _ = self.wallet.localstore.delete_saga(&operation_id).await;
}
}
impl<'a> MeltSaga<'a, Finalized> {
pub fn quote_id(&self) -> &str {
&self.state_data.quote_id
}
pub fn state(&self) -> MeltQuoteState {
self.state_data.state
}
pub fn amount(&self) -> Amount {
self.state_data.amount
}
pub fn fee_paid(&self) -> Amount {
self.state_data.fee
}
pub fn payment_proof(&self) -> Option<&str> {
self.state_data.payment_proof.as_deref()
}
pub fn into_change(self) -> Option<Proofs> {
self.state_data.change
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use cdk_common::amount::{FeeAndAmounts, SplitTarget};
use cdk_common::nut00::KnownMethod;
use cdk_common::nuts::nut30::MeltQuoteOnchainFeeOption;
use cdk_common::nuts::{CurrencyUnit, State};
use cdk_common::wallet::OperationData;
use cdk_common::{MeltQuoteOnchainResponse, MeltQuoteResponse, MeltQuoteState, PaymentMethod};
use uuid::Uuid;
use super::{finalize_melt_common, MeltSaga, MeltSagaResult};
use crate::nuts::{BlindSignature, PreMintSecrets};
use crate::wallet::saga::new_compensations;
use crate::wallet::test_utils::{
create_test_db, create_test_wallet_with_mock, test_keyset_id, test_melt_quote,
test_mint_url, test_proof_info, MockMintConnector,
};
use crate::wallet::MeltConfirmOptions;
use crate::{Amount, Error};
#[tokio::test]
async fn test_prepare_melt_reserves_exact_input_proofs_for_operation() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let keyset_id = test_keyset_id();
let proof_info = test_proof_info(keyset_id, 1010, mint_url.clone());
let proof_y = proof_info.y;
let proof = proof_info.proof.clone();
db.update_proofs(vec![proof_info], vec![]).await.unwrap();
let quote = test_melt_quote();
let quote_id = quote.id.clone();
db.add_melt_quote(quote).await.unwrap();
let mock_client = Arc::new(MockMintConnector::new());
mock_client.reset_default_mint_state();
let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
let saga = MeltSaga::new(&wallet);
let prepared = saga
.prepare_with_proofs("e_id, vec![proof], HashMap::new())
.await
.unwrap();
let reserved = db
.get_reserved_proofs(&prepared.state_data.operation_id)
.await
.unwrap();
assert_eq!(reserved.len(), 1);
assert_eq!(reserved[0].y, proof_y);
assert_eq!(reserved[0].state, State::Reserved);
let stored = db.get_proofs_by_ys(vec![proof_y]).await.unwrap();
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].state, State::Reserved);
assert_eq!(
stored[0].used_by_operation,
Some(prepared.state_data.operation_id)
);
}
#[tokio::test]
async fn test_prepare_melt_reserves_swap_input_proofs_for_operation() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let keyset_id = test_keyset_id();
let proof_info = test_proof_info(keyset_id, 2000, mint_url.clone());
let proof_y = proof_info.y;
db.update_proofs(vec![proof_info], vec![]).await.unwrap();
let quote = test_melt_quote();
let quote_id = quote.id.clone();
db.add_melt_quote(quote).await.unwrap();
let mock_client = Arc::new(MockMintConnector::new());
mock_client.reset_default_mint_state();
let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
let saga = MeltSaga::new(&wallet);
let prepared = saga.prepare("e_id, HashMap::new()).await.unwrap();
assert_eq!(prepared.state_data.proofs_to_swap.len(), 1);
let reserved = db
.get_reserved_proofs(&prepared.state_data.operation_id)
.await
.unwrap();
assert_eq!(reserved.len(), 1);
assert_eq!(reserved[0].y, proof_y);
assert_eq!(reserved[0].state, State::Reserved);
let stored = db.get_proofs_by_ys(vec![proof_y]).await.unwrap();
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].state, State::Reserved);
assert_eq!(
stored[0].used_by_operation,
Some(prepared.state_data.operation_id)
);
}
#[tokio::test]
async fn test_onchain_melt_sends_change_outputs_when_change_expected() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let keyset_id = test_keyset_id();
let proof_info = test_proof_info(keyset_id, 1200, mint_url.clone());
let proof = proof_info.proof.clone();
let mut quote = test_melt_quote();
quote.amount = Amount::from(1000);
quote.fee_reserve = Amount::from(10);
quote.payment_method = PaymentMethod::Known(KnownMethod::Onchain);
quote.request = "bcrt1qtestaddress".to_string();
quote.estimated_blocks = Some(6);
quote.fee_index = Some(0);
let quote_id = quote.id.clone();
db.add_melt_quote(quote.clone()).await.unwrap();
let mock_client = Arc::new(MockMintConnector::new());
mock_client.reset_default_mint_state();
mock_client.set_post_melt_response(Ok(MeltQuoteResponse::Onchain(
MeltQuoteOnchainResponse {
quote: quote_id.clone(),
amount: quote.amount,
unit: CurrencyUnit::Sat,
state: MeltQuoteState::Pending,
expiry: quote.expiry,
request: quote.request.clone(),
fee_options: vec![MeltQuoteOnchainFeeOption {
fee_index: 0,
fee_reserve: quote.fee_reserve,
estimated_blocks: 6,
}],
selected_fee_index: Some(0),
outpoint: None,
change: None,
},
)));
let wallet = create_test_wallet_with_mock(db.clone(), mock_client.clone()).await;
let saga = MeltSaga::new(&wallet);
let requested = saga
.prepare_with_proofs("e_id, vec![proof], HashMap::new())
.await
.unwrap()
.request_melt_with_options(MeltConfirmOptions::new())
.await
.unwrap();
let stored_saga = db
.get_saga(&requested.state_data.operation_id)
.await
.unwrap()
.expect("melt saga must be stored");
let OperationData::Melt(data) = stored_saga.data else {
panic!("stored saga must contain melt operation data");
};
assert!(
data.change_amount
.is_some_and(|amount| amount > Amount::ZERO),
"onchain melt should record expected change amount"
);
assert!(
data.change_blinded_messages
.as_ref()
.is_some_and(|outputs| !outputs.is_empty()),
"onchain melt should persist blinded change outputs for recovery"
);
let _result = requested.execute_async(HashMap::new()).await.unwrap();
let (method, request) = mock_client
.last_post_melt_request()
.expect("post_melt request must be captured");
assert_eq!(method, PaymentMethod::Known(KnownMethod::Onchain));
assert_eq!(request.selected_fee_index(), Some(0));
assert!(
request
.outputs()
.as_ref()
.is_some_and(|outputs| !outputs.is_empty()),
"onchain melt request should include blinded change outputs"
);
}
fn onchain_melt_response(
quote: &cdk_common::wallet::MeltQuote,
state: MeltQuoteState,
) -> MeltQuoteResponse<String> {
MeltQuoteResponse::Onchain(MeltQuoteOnchainResponse {
quote: quote.id.clone(),
amount: quote.amount,
unit: CurrencyUnit::Sat,
state,
expiry: quote.expiry,
request: quote.request.clone(),
fee_options: vec![MeltQuoteOnchainFeeOption {
fee_index: quote.fee_index.unwrap_or(0),
fee_reserve: quote.fee_reserve,
estimated_blocks: quote
.estimated_blocks
.expect("test onchain quote must set estimated_blocks"),
}],
selected_fee_index: quote.fee_index,
outpoint: None,
change: None,
})
}
async fn requested_onchain_melt_with_response(
state: MeltQuoteState,
) -> (
Arc<dyn cdk_common::database::WalletDatabase<cdk_common::database::Error> + Send + Sync>,
crate::nuts::PublicKey,
super::MeltSaga<'static, super::state::MeltRequested>,
) {
let db = create_test_db().await;
let mint_url = test_mint_url();
let keyset_id = test_keyset_id();
let proof_info = test_proof_info(keyset_id, 1200, mint_url);
let proof_y = proof_info.y;
let proof = proof_info.proof.clone();
let mut quote = test_melt_quote();
quote.amount = Amount::from(1000);
quote.fee_reserve = Amount::from(10);
quote.payment_method = PaymentMethod::Known(KnownMethod::Onchain);
quote.request = "bcrt1qtestaddress".to_string();
quote.estimated_blocks = Some(6);
quote.fee_index = Some(0);
let quote_id = quote.id.clone();
db.add_melt_quote(quote.clone()).await.unwrap();
let mock_client = Arc::new(MockMintConnector::new());
mock_client.reset_default_mint_state();
mock_client.set_post_melt_response(Ok(onchain_melt_response("e, state)));
let wallet = Box::leak(Box::new(
create_test_wallet_with_mock(db.clone(), mock_client).await,
));
let requested = MeltSaga::new(wallet)
.prepare_with_proofs("e_id, vec![proof], HashMap::new())
.await
.unwrap()
.request_melt_with_options(MeltConfirmOptions::new())
.await
.unwrap();
(db, proof_y, requested)
}
#[tokio::test]
async fn test_execute_async_unpaid_response_compensates_instead_of_finalizing() {
let (db, proof_y, requested) =
requested_onchain_melt_with_response(MeltQuoteState::Unpaid).await;
let operation_id = requested.state_data.operation_id;
let result = requested.execute_async(HashMap::new()).await;
assert!(matches!(result, Err(Error::PaymentFailed)));
let stored = db.get_proofs_by_ys(vec![proof_y]).await.unwrap();
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].state, State::Unspent);
assert!(
db.get_saga(&operation_id).await.unwrap().is_none(),
"compensated melt saga should be deleted"
);
}
#[tokio::test]
async fn test_execute_async_unknown_response_stays_pending_instead_of_finalizing() {
let (db, proof_y, requested) =
requested_onchain_melt_with_response(MeltQuoteState::Unknown).await;
let operation_id = requested.state_data.operation_id;
let result = requested.execute_async(HashMap::new()).await.unwrap();
assert!(matches!(result, MeltSagaResult::Pending(_)));
let stored = db.get_proofs_by_ys(vec![proof_y]).await.unwrap();
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].state, State::Pending);
assert_eq!(stored[0].used_by_operation, Some(operation_id));
assert!(
db.get_saga(&operation_id).await.unwrap().is_some(),
"pending melt saga should remain for recovery"
);
}
#[tokio::test]
async fn test_finalize_melt_rejects_oversized_change_from_mint() {
let db = create_test_db().await;
let mock_client = Arc::new(MockMintConnector::new());
mock_client.reset_default_mint_state();
let wallet = create_test_wallet_with_mock(db, mock_client).await;
let keyset_id = test_keyset_id();
let quote = test_melt_quote();
let final_proofs = vec![test_proof_info(keyset_id, 1000, test_mint_url()).proof];
let fee_and_amounts = FeeAndAmounts::from((0, vec![1]));
let premint_secrets = PreMintSecrets::random(
keyset_id,
Amount::from(1),
&SplitTarget::None,
&fee_and_amounts,
)
.unwrap();
let oversized_change = vec![BlindSignature {
amount: Amount::from(1),
keyset_id,
c: premint_secrets.blinded_messages()[0].blinded_secret,
dleq: None,
}];
let result = finalize_melt_common(
&wallet,
new_compensations(),
Uuid::new_v4(),
"e,
&final_proofs,
&premint_secrets,
MeltQuoteState::Paid,
None,
Some(oversized_change),
HashMap::new(),
)
.await;
assert!(matches!(result, Err(Error::AmountOverflow)));
}
#[tokio::test]
async fn test_finalize_melt_accepts_change_amount_for_zero_amount_output() {
let db = create_test_db().await;
let mock_client = Arc::new(MockMintConnector::new());
mock_client.reset_default_mint_state();
let wallet = create_test_wallet_with_mock(db, mock_client).await;
let keyset_id = test_keyset_id();
let quote = test_melt_quote();
let final_proofs = vec![test_proof_info(keyset_id, 1008, test_mint_url()).proof];
let premint_secrets =
PreMintSecrets::blank(keyset_id, Amount::from(8)).expect("blank premint secrets");
let change = vec![BlindSignature {
amount: Amount::from(8),
keyset_id,
c: premint_secrets.blinded_messages()[0].blinded_secret,
dleq: None,
}];
let result = finalize_melt_common(
&wallet,
new_compensations(),
Uuid::new_v4(),
"e,
&final_proofs,
&premint_secrets,
MeltQuoteState::Paid,
None,
Some(change),
HashMap::new(),
)
.await;
assert!(result.is_ok());
assert_eq!(
result.unwrap().into_change().expect("change proofs")[0].amount,
Amount::from(8)
);
}
}