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;
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::ProofsMethods;
use crate::nuts::{MeltRequest, PreMintSecrets, Proofs, State};
use crate::util::unix_time;
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_preimage: 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,
};
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 - quote_info.amount - change_total;
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_preimage.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: payment_preimage,
change: change_proofs,
},
})
}
impl<'a> MeltSaga<'a, Initial> {
pub fn new(wallet: &'a Wallet) -> Self {
let operation_id = uuid::Uuid::new_v4();
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(),
Some(self.state_data.premint_secrets.blinded_messages()),
)
.prefer_async(true);
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 => {
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_preimage,
melt_response.change,
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 => {
self.handle_failure().await;
Err(Error::PaymentFailed)
}
_ => {
tracing::warn!(
"Melt quote {} returned unexpected state {:?}",
quote_info.id,
melt_response.state
);
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_preimage,
melt_response.change,
metadata,
)
.await?;
Ok(MeltSagaResult::Finalized(finalized))
}
}
}
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_preimage: 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_preimage,
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::nuts::State;
use super::MeltSaga;
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,
};
#[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)
);
}
}