use cdk_common::wallet::{MeltOperationData, MeltSagaState, OperationData, WalletSaga};
use cdk_common::{Amount, MeltQuoteState};
use tracing::instrument;
use crate::nuts::State;
use crate::types::FinalizedMelt;
use crate::wallet::melt::saga::compensation::ReleaseMeltQuote;
use crate::wallet::melt::MeltQuoteStatusResponse;
use crate::wallet::recovery::RecoveryHelpers;
use crate::wallet::saga::{CompensatingAction, RevertProofReservation};
use crate::{Error, Wallet};
impl Wallet {
#[instrument(skip(self, saga))]
pub(crate) async fn resume_melt_saga(
&self,
saga: &WalletSaga,
) -> Result<Option<FinalizedMelt>, Error> {
let state = match &saga.state {
cdk_common::wallet::WalletSagaState::Melt(s) => s,
_ => {
return Err(Error::Custom(format!(
"Invalid saga state type for melt saga {}",
saga.id
)))
}
};
let data = match &saga.data {
OperationData::Melt(d) => d,
_ => {
return Err(Error::Custom(format!(
"Invalid operation data type for melt saga {}",
saga.id
)))
}
};
match state {
MeltSagaState::ProofsReserved => {
tracing::info!(
"Melt saga {} in ProofsReserved state - compensating",
saga.id
);
self.compensate_melt(&saga.id).await?;
Ok(Some(FinalizedMelt::new(
data.quote_id.clone(),
MeltQuoteState::Unpaid,
None,
data.amount,
Amount::ZERO,
None,
)))
}
MeltSagaState::MeltRequested | MeltSagaState::PaymentPending => {
tracing::info!(
"Melt saga {} in {:?} state - checking quote state",
saga.id,
state
);
self.recover_or_compensate_melt(&saga.id, data).await
}
}
}
async fn recover_or_compensate_melt(
&self,
saga_id: &uuid::Uuid,
data: &MeltOperationData,
) -> Result<Option<FinalizedMelt>, Error> {
match self.internal_check_melt_status(&data.quote_id).await {
Ok(quote_status) => match quote_status.state() {
MeltQuoteState::Paid => {
tracing::info!("Melt saga {} - payment succeeded, finalizing", saga_id);
let melted = self
.complete_melt_from_restore(saga_id, data, "e_status)
.await?;
Ok(Some(melted))
}
MeltQuoteState::Unpaid | MeltQuoteState::Failed => {
tracing::info!("Melt saga {} - payment failed, compensating", saga_id);
self.compensate_melt(saga_id).await?;
Ok(Some(FinalizedMelt::new(
data.quote_id.clone(),
quote_status.state(),
None,
data.amount,
Amount::ZERO,
None,
)))
}
MeltQuoteState::Pending | MeltQuoteState::Unknown => {
tracing::info!("Melt saga {} - payment pending/unknown, skipping", saga_id);
Ok(None)
}
},
Err(e) => {
tracing::warn!(
"Melt saga {} - can't check quote state ({}), skipping",
saga_id,
e
);
Ok(None)
}
}
}
async fn complete_melt_from_restore(
&self,
saga_id: &uuid::Uuid,
data: &MeltOperationData,
quote_status: &MeltQuoteStatusResponse,
) -> Result<FinalizedMelt, Error> {
let reserved_proofs = self.localstore.get_reserved_proofs(saga_id).await?;
let input_amount =
Amount::try_sum(reserved_proofs.iter().map(|p| p.proof.amount)).unwrap_or(Amount::ZERO);
if !reserved_proofs.is_empty() {
let proof_ys: Vec<_> = reserved_proofs.iter().map(|p| p.y).collect();
self.localstore
.update_proofs_state(proof_ys, State::Spent)
.await?;
}
let change_proofs = if let Some(ref change_blinded_messages) = data.change_blinded_messages
{
if !change_blinded_messages.is_empty() {
match self
.restore_outputs(
saga_id,
"Melt",
Some(change_blinded_messages.as_slice()),
data.counter_start,
data.counter_end,
)
.await
{
Ok(Some(change_proof_infos)) => {
let proofs: Vec<_> =
change_proof_infos.iter().map(|p| p.proof.clone()).collect();
self.localstore
.update_proofs(change_proof_infos, vec![])
.await?;
Some(proofs)
}
Ok(None) => {
tracing::warn!(
"Melt saga {} - couldn't restore change proofs. \
Run wallet.restore() to recover any missing change.",
saga_id
);
None
}
Err(e) => {
tracing::warn!(
"Melt saga {} - failed to recover change: {}. \
Run wallet.restore() to recover any missing change.",
saga_id,
e
);
None
}
}
} else {
None
}
} else {
tracing::warn!(
"Melt saga {} - payment succeeded but no change blinded messages stored. \
Run wallet.restore() to recover any missing change.",
saga_id
);
None
};
let change_amount = change_proofs
.as_ref()
.and_then(|p| Amount::try_sum(p.iter().map(|proof| proof.amount)).ok())
.unwrap_or(Amount::ZERO);
let fee_paid = input_amount
.checked_sub(data.amount + change_amount)
.unwrap_or(Amount::ZERO);
self.localstore.delete_saga(saga_id).await?;
Ok(FinalizedMelt::new(
data.quote_id.clone(),
MeltQuoteState::Paid,
quote_status.payment_preimage(),
data.amount,
fee_paid,
change_proofs,
))
}
async fn compensate_melt(&self, saga_id: &uuid::Uuid) -> Result<(), Error> {
if let Err(e) = (ReleaseMeltQuote {
localstore: self.localstore.clone(),
operation_id: *saga_id,
}
.execute()
.await)
{
tracing::warn!(
"Failed to release melt quote for saga {}: {}. Continuing with saga cleanup.",
saga_id,
e
);
}
let reserved_proofs = self.localstore.get_reserved_proofs(saga_id).await?;
let proof_ys = reserved_proofs.iter().map(|p| p.y).collect();
RevertProofReservation {
localstore: self.localstore.clone(),
proof_ys,
saga_id: *saga_id,
}
.execute()
.await
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use cdk_common::nuts::{CurrencyUnit, State};
use cdk_common::wallet::{
MeltOperationData, MeltSagaState, OperationData, WalletSaga, WalletSagaState,
};
use cdk_common::{Amount, MeltQuoteBolt11Response, MeltQuoteState};
use crate::wallet::saga::test_utils::{
create_test_db, test_keyset_id, test_mint_url, test_proof_info,
};
use crate::wallet::test_utils::{
create_test_wallet_with_mock, test_melt_quote, MockMintConnector,
};
#[tokio::test]
async fn test_recover_melt_proofs_reserved() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let keyset_id = test_keyset_id();
let saga_id = uuid::Uuid::new_v4();
let quote_id = format!("test_melt_quote_{}", uuid::Uuid::new_v4());
let proof_info = test_proof_info(keyset_id, 100, mint_url.clone(), State::Unspent);
let proof_y = proof_info.y;
db.update_proofs(vec![proof_info], vec![]).await.unwrap();
db.reserve_proofs(vec![proof_y], &saga_id).await.unwrap();
let mut melt_quote = test_melt_quote();
melt_quote.id = quote_id.clone();
db.add_melt_quote(melt_quote).await.unwrap();
db.reserve_melt_quote("e_id, &saga_id).await.unwrap();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Melt(MeltSagaState::ProofsReserved),
Amount::from(100),
mint_url.clone(),
CurrencyUnit::Sat,
OperationData::Melt(MeltOperationData {
quote_id,
amount: Amount::from(100),
fee_reserve: Amount::from(10),
counter_start: None,
counter_end: None,
change_amount: None,
change_blinded_messages: None,
}),
);
db.add_saga(saga).await.unwrap();
let mock_client = Arc::new(MockMintConnector::new());
let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
let result = wallet
.resume_melt_saga(&db.get_saga(&saga_id).await.unwrap().unwrap())
.await
.unwrap();
assert!(result.is_some());
let finalized = result.unwrap();
assert_eq!(finalized.state(), MeltQuoteState::Unpaid);
let proofs = db
.get_proofs(None, None, Some(vec![State::Unspent]), None)
.await
.unwrap();
assert_eq!(proofs.len(), 1);
assert!(db.get_saga(&saga_id).await.unwrap().is_none());
}
#[tokio::test]
async fn test_recover_melt_proofs_reserved_without_operation_link_leaves_reserved_proof() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let keyset_id = test_keyset_id();
let saga_id = uuid::Uuid::new_v4();
let quote_id = format!("test_melt_quote_{}", uuid::Uuid::new_v4());
let proof_info = test_proof_info(keyset_id, 100, mint_url.clone(), State::Unspent);
let proof_y = proof_info.y;
db.update_proofs(vec![proof_info], vec![]).await.unwrap();
db.update_proofs_state(vec![proof_y], State::Reserved)
.await
.unwrap();
let mut melt_quote = test_melt_quote();
melt_quote.id = quote_id.clone();
db.add_melt_quote(melt_quote).await.unwrap();
db.reserve_melt_quote("e_id, &saga_id).await.unwrap();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Melt(MeltSagaState::ProofsReserved),
Amount::from(100),
mint_url,
CurrencyUnit::Sat,
OperationData::Melt(MeltOperationData {
quote_id,
amount: Amount::from(100),
fee_reserve: Amount::from(10),
counter_start: None,
counter_end: None,
change_amount: None,
change_blinded_messages: None,
}),
);
db.add_saga(saga).await.unwrap();
let mock_client = Arc::new(MockMintConnector::new());
let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
let result = wallet
.resume_melt_saga(&db.get_saga(&saga_id).await.unwrap().unwrap())
.await
.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().state(), MeltQuoteState::Unpaid);
let reserved = db.get_proofs_by_ys(vec![proof_y]).await.unwrap();
assert_eq!(reserved.len(), 1);
assert_eq!(reserved[0].state, State::Reserved);
assert_eq!(reserved[0].used_by_operation, None);
assert!(db.get_saga(&saga_id).await.unwrap().is_none());
}
#[tokio::test]
async fn test_recover_melt_melt_requested_quote_paid() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let keyset_id = test_keyset_id();
let saga_id = uuid::Uuid::new_v4();
let quote_id = format!("test_melt_quote_{}", uuid::Uuid::new_v4());
let proof_info = test_proof_info(keyset_id, 100, mint_url.clone(), State::Unspent);
let proof_y = proof_info.y;
db.update_proofs(vec![proof_info], vec![]).await.unwrap();
db.reserve_proofs(vec![proof_y], &saga_id).await.unwrap();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Melt(MeltSagaState::MeltRequested),
Amount::from(100),
mint_url.clone(),
CurrencyUnit::Sat,
OperationData::Melt(MeltOperationData {
quote_id: quote_id.clone(),
amount: Amount::from(100),
fee_reserve: Amount::from(10),
counter_start: None,
counter_end: None,
change_amount: None,
change_blinded_messages: None,
}),
);
db.add_saga(saga).await.unwrap();
let mut melt_quote = test_melt_quote();
melt_quote.id = quote_id.clone();
db.add_melt_quote(melt_quote).await.unwrap();
let mock_client = Arc::new(MockMintConnector::new());
mock_client.set_melt_quote_status_response(Ok(MeltQuoteBolt11Response {
quote: quote_id,
state: MeltQuoteState::Paid,
expiry: 9999999999,
fee_reserve: Amount::from(10),
amount: Amount::from(100),
request: Some("lnbc100...".to_string()),
payment_preimage: Some("preimage123".to_string()),
change: None,
unit: Some(CurrencyUnit::Sat),
}));
let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
let result = wallet
.resume_melt_saga(&db.get_saga(&saga_id).await.unwrap().unwrap())
.await
.unwrap();
assert!(result.is_some());
let finalized = result.unwrap();
assert_eq!(finalized.state(), MeltQuoteState::Paid);
let proofs = db
.get_proofs(None, None, Some(vec![State::Spent]), None)
.await
.unwrap();
assert_eq!(proofs.len(), 1);
assert!(db.get_saga(&saga_id).await.unwrap().is_none());
}
#[tokio::test]
async fn test_recover_melt_melt_requested_quote_unpaid() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let keyset_id = test_keyset_id();
let saga_id = uuid::Uuid::new_v4();
let quote_id = format!("test_melt_quote_{}", uuid::Uuid::new_v4());
let proof_info = test_proof_info(keyset_id, 100, mint_url.clone(), State::Unspent);
let proof_y = proof_info.y;
db.update_proofs(vec![proof_info], vec![]).await.unwrap();
db.reserve_proofs(vec![proof_y], &saga_id).await.unwrap();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Melt(MeltSagaState::MeltRequested),
Amount::from(100),
mint_url.clone(),
CurrencyUnit::Sat,
OperationData::Melt(MeltOperationData {
quote_id: quote_id.clone(),
amount: Amount::from(100),
fee_reserve: Amount::from(10),
counter_start: None,
counter_end: None,
change_amount: None,
change_blinded_messages: None,
}),
);
db.add_saga(saga).await.unwrap();
let mut melt_quote = test_melt_quote();
melt_quote.id = quote_id.clone();
db.add_melt_quote(melt_quote).await.unwrap();
let mock_client = Arc::new(MockMintConnector::new());
mock_client.set_melt_quote_status_response(Ok(MeltQuoteBolt11Response {
quote: quote_id,
state: MeltQuoteState::Unpaid,
expiry: 9999999999,
fee_reserve: Amount::from(10),
amount: Amount::from(100),
request: Some("lnbc100...".to_string()),
payment_preimage: None,
change: None,
unit: Some(CurrencyUnit::Sat),
}));
let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
let result = wallet
.resume_melt_saga(&db.get_saga(&saga_id).await.unwrap().unwrap())
.await
.unwrap();
assert!(result.is_some());
let finalized = result.unwrap();
assert!(
finalized.state() == MeltQuoteState::Unpaid
|| finalized.state() == MeltQuoteState::Failed
);
let proofs = db
.get_proofs(None, None, Some(vec![State::Unspent]), None)
.await
.unwrap();
assert_eq!(proofs.len(), 1);
assert!(db.get_saga(&saga_id).await.unwrap().is_none());
}
#[tokio::test]
async fn test_recover_melt_melt_requested_quote_pending() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let keyset_id = test_keyset_id();
let saga_id = uuid::Uuid::new_v4();
let quote_id = format!("test_melt_quote_{}", uuid::Uuid::new_v4());
let proof_info = test_proof_info(keyset_id, 100, mint_url.clone(), State::Unspent);
let proof_y = proof_info.y;
db.update_proofs(vec![proof_info], vec![]).await.unwrap();
db.reserve_proofs(vec![proof_y], &saga_id).await.unwrap();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Melt(MeltSagaState::MeltRequested),
Amount::from(100),
mint_url.clone(),
CurrencyUnit::Sat,
OperationData::Melt(MeltOperationData {
quote_id: quote_id.clone(),
amount: Amount::from(100),
fee_reserve: Amount::from(10),
counter_start: None,
counter_end: None,
change_amount: None,
change_blinded_messages: None,
}),
);
db.add_saga(saga).await.unwrap();
let mut melt_quote = test_melt_quote();
melt_quote.id = quote_id.clone();
db.add_melt_quote(melt_quote).await.unwrap();
let mock_client = Arc::new(MockMintConnector::new());
mock_client.set_melt_quote_status_response(Ok(MeltQuoteBolt11Response {
quote: quote_id,
state: MeltQuoteState::Pending,
expiry: 9999999999,
fee_reserve: Amount::from(10),
amount: Amount::from(100),
request: Some("lnbc100...".to_string()),
payment_preimage: None,
change: None,
unit: Some(CurrencyUnit::Sat),
}));
let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
let result = wallet
.resume_melt_saga(&db.get_saga(&saga_id).await.unwrap().unwrap())
.await
.unwrap();
assert!(result.is_none());
let reserved = db.get_reserved_proofs(&saga_id).await.unwrap();
assert_eq!(reserved.len(), 1);
assert!(db.get_saga(&saga_id).await.unwrap().is_some());
}
}