use async_trait::async_trait;
use cdk_common::wallet::{ProofInfo, WalletSagaState};
use cdk_common::BlindedMessage;
use tracing::instrument;
use crate::dhke::construct_proofs;
use crate::nuts::{CheckStateRequest, PreMintSecrets, Proofs, RestoreRequest, State, SwapRequest};
use crate::{Error, Wallet};
struct OutputRecoveryParams<'a> {
blinded_messages: &'a [BlindedMessage],
counter_start: u32,
counter_end: u32,
}
#[derive(Debug, Default)]
pub struct RecoveryReport {
pub recovered: usize,
pub compensated: usize,
pub skipped: usize,
pub failed: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecoveryAction {
Recovered,
Compensated,
Skipped,
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait RecoveryHelpers {
async fn are_proofs_spent(&self, proofs: &[ProofInfo]) -> Result<bool, Error>;
async fn restore_outputs(
&self,
saga_id: &uuid::Uuid,
saga_type: &str,
blinded_messages: Option<&[BlindedMessage]>,
counter_start: Option<u32>,
counter_end: Option<u32>,
) -> Result<Option<Vec<ProofInfo>>, Error>;
async fn try_replay_swap_request(
&self,
saga_id: &uuid::Uuid,
saga_type: &str,
blinded_messages: Option<&[BlindedMessage]>,
counter_start: Option<u32>,
counter_end: Option<u32>,
input_proofs: &[ProofInfo],
) -> Result<Option<Vec<ProofInfo>>, Error>;
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl RecoveryHelpers for Wallet {
async fn are_proofs_spent(&self, proofs: &[ProofInfo]) -> Result<bool, Error> {
if proofs.is_empty() {
return Ok(false);
}
let ys: Vec<_> = proofs.iter().map(|p| p.y).collect();
let response = self
.client
.post_check_state(CheckStateRequest { ys })
.await?;
Ok(response.states.iter().all(|s| s.state == State::Spent))
}
async fn restore_outputs(
&self,
saga_id: &uuid::Uuid,
saga_type: &str,
blinded_messages: Option<&[BlindedMessage]>,
counter_start: Option<u32>,
counter_end: Option<u32>,
) -> Result<Option<Vec<ProofInfo>>, Error> {
let blinded_messages_owned = blinded_messages.map(|bm| bm.to_vec());
let params = match Self::extract_recovery_params(
saga_id,
saga_type,
blinded_messages_owned.as_ref(),
counter_start,
counter_end,
) {
Some(p) => p,
None => return Ok(None),
};
self.recover_outputs_from_blinded_messages(saga_id, saga_type, params)
.await
}
async fn try_replay_swap_request(
&self,
saga_id: &uuid::Uuid,
saga_type: &str,
blinded_messages: Option<&[BlindedMessage]>,
counter_start: Option<u32>,
counter_end: Option<u32>,
input_proofs: &[ProofInfo],
) -> Result<Option<Vec<ProofInfo>>, Error> {
let blinded_messages = match blinded_messages {
Some(bm) if !bm.is_empty() => bm,
_ => {
tracing::debug!(
"{} saga {} - no blinded messages stored, cannot replay",
saga_type,
saga_id
);
return Ok(None);
}
};
let (counter_start, counter_end) = match (counter_start, counter_end) {
(Some(start), Some(end)) => (start, end),
_ => {
tracing::debug!(
"{} saga {} - no counter range stored, cannot replay",
saga_type,
saga_id
);
return Ok(None);
}
};
let inputs: Proofs = input_proofs.iter().map(|pi| pi.proof.clone()).collect();
if inputs.is_empty() {
tracing::debug!(
"{} saga {} - no input proofs available, cannot replay",
saga_type,
saga_id
);
return Ok(None);
}
let swap_request = SwapRequest::new(inputs, blinded_messages.to_vec());
tracing::info!(
"{} saga {} - attempting replay of post_swap request",
saga_type,
saga_id
);
let swap_response = match self.client.post_swap(swap_request).await {
Ok(response) => response,
Err(e) => {
tracing::info!(
"{} saga {} - replay failed ({}), falling back to other recovery",
saga_type,
saga_id,
e
);
return Ok(None);
}
};
tracing::info!(
"{} saga {} - replay succeeded, got {} signatures",
saga_type,
saga_id,
swap_response.signatures.len()
);
let keyset_id = blinded_messages[0].keyset_id;
let premint_secrets =
PreMintSecrets::restore_batch(keyset_id, &self.seed, counter_start, counter_end)?;
let keys = self.load_keyset_keys(keyset_id).await?;
let proofs = construct_proofs(
swap_response.signatures,
premint_secrets.rs(),
premint_secrets.secrets(),
&keys,
)?;
let proof_infos: Vec<ProofInfo> = proofs
.into_iter()
.map(|p| ProofInfo::new(p, self.mint_url.clone(), State::Unspent, self.unit.clone()))
.collect::<Result<Vec<_>, _>>()?;
Ok(Some(proof_infos))
}
}
impl Wallet {
#[instrument(skip(self))]
pub async fn recover_incomplete_sagas(&self) -> Result<RecoveryReport, Error> {
self.cleanup_orphaned_quote_reservations().await?;
let sagas = self.localstore.get_incomplete_sagas().await?;
let sagas: Vec<_> = sagas
.into_iter()
.filter(|s| s.mint_url == self.mint_url && s.unit == self.unit)
.collect();
if sagas.is_empty() {
tracing::debug!("No incomplete sagas to recover");
return Ok(RecoveryReport::default());
}
tracing::info!("Found {} incomplete saga(s) to recover", sagas.len());
let mut report = RecoveryReport::default();
for saga in sagas {
tracing::info!(
"Recovering saga {} (kind: {:?}, state: {})",
saga.id,
saga.kind,
saga.state.state_str()
);
let result: Result<RecoveryAction, Error> = match &saga.state {
WalletSagaState::Swap(_) => self.resume_swap_saga(&saga).await,
WalletSagaState::Send(_) => self.resume_send_saga(&saga).await,
WalletSagaState::Receive(_) => self.resume_receive_saga(&saga).await,
WalletSagaState::Issue(_) => self.resume_issue_saga(&saga).await,
WalletSagaState::Melt(_) => {
self.resume_melt_saga(&saga).await.map(|opt| match opt {
Some(finalized) => {
use cdk_common::MeltQuoteState;
if finalized.state() == MeltQuoteState::Paid {
RecoveryAction::Recovered
} else {
RecoveryAction::Compensated
}
}
None => RecoveryAction::Skipped,
})
}
};
match result {
Ok(RecoveryAction::Recovered) => {
tracing::info!("Saga {} recovered successfully", saga.id);
report.recovered += 1;
}
Ok(RecoveryAction::Compensated) => {
tracing::info!("Saga {} compensated (rolled back)", saga.id);
report.compensated += 1;
}
Ok(RecoveryAction::Skipped) => {
tracing::info!("Saga {} skipped", saga.id);
report.skipped += 1;
}
Err(e) => {
tracing::error!("Failed to recover saga {}: {}", saga.id, e);
report.failed += 1;
}
}
}
tracing::info!(
"Recovery complete: {} recovered, {} compensated, {} skipped, {} failed",
report.recovered,
report.compensated,
report.skipped,
report.failed
);
Ok(report)
}
#[instrument(skip(self, params))]
async fn recover_outputs_from_blinded_messages(
&self,
saga_id: &uuid::Uuid,
saga_type: &str,
params: OutputRecoveryParams<'_>,
) -> Result<Option<Vec<ProofInfo>>, Error> {
tracing::info!(
"{} saga {} - attempting to recover {} outputs using stored blinded messages",
saga_type,
saga_id,
params.blinded_messages.len()
);
let restore_request = RestoreRequest {
outputs: params.blinded_messages.to_vec(),
};
let restore_response = match self.client.post_restore(restore_request).await {
Ok(response) => response,
Err(e) => {
if e.is_definitive_failure() {
tracing::warn!(
"{} saga {} - failed to restore from mint (definitive): {}. \
Run wallet.restore() to recover any missing proofs.",
saga_type,
saga_id,
e
);
return Ok(None);
} else {
tracing::warn!(
"{} saga {} - failed to restore from mint (ambiguous): {}. \
Skipping recovery to retry later.",
saga_type,
saga_id,
e
);
return Err(e);
}
}
};
if restore_response.signatures.is_empty() {
tracing::warn!(
"{} saga {} - mint returned no signatures. \
Outputs may have already been saved or mint doesn't have them.",
saga_type,
saga_id
);
return Ok(None);
}
let keyset_id = params.blinded_messages[0].keyset_id;
let premint_secrets = PreMintSecrets::restore_batch(
keyset_id,
&self.seed,
params.counter_start,
params.counter_end,
)?;
let matched_secrets: Vec<_> = premint_secrets
.secrets
.iter()
.filter(|p| restore_response.outputs.contains(&p.blinded_message))
.collect();
if matched_secrets.len() != restore_response.signatures.len() {
tracing::warn!(
"{} saga {} - signature count mismatch: {} secrets, {} signatures",
saga_type,
saga_id,
matched_secrets.len(),
restore_response.signatures.len()
);
}
let keys = self.load_keyset_keys(keyset_id).await?;
let proofs = construct_proofs(
restore_response.signatures,
matched_secrets.iter().map(|p| p.r.clone()).collect(),
matched_secrets.iter().map(|p| p.secret.clone()).collect(),
&keys,
)?;
tracing::info!(
"{} saga {} - recovered {} proofs",
saga_type,
saga_id,
proofs.len()
);
let proof_infos: Vec<ProofInfo> = proofs
.into_iter()
.map(|p| ProofInfo::new(p, self.mint_url.clone(), State::Unspent, self.unit.clone()))
.collect::<Result<Vec<_>, _>>()?;
Ok(Some(proof_infos))
}
fn extract_recovery_params<'a>(
saga_id: &uuid::Uuid,
saga_type: &str,
blinded_messages: Option<&'a Vec<BlindedMessage>>,
counter_start: Option<u32>,
counter_end: Option<u32>,
) -> Option<OutputRecoveryParams<'a>> {
let blinded_messages = match blinded_messages {
Some(bm) if !bm.is_empty() => bm,
_ => {
tracing::warn!(
"{} saga {} - no blinded messages stored, cannot recover outputs. \
Run wallet.restore() to recover any missing proofs.",
saga_type,
saga_id
);
return None;
}
};
let (counter_start, counter_end) = match (counter_start, counter_end) {
(Some(start), Some(end)) => (start, end),
_ => {
tracing::warn!(
"{} saga {} - no counter range stored, cannot recover outputs. \
Run wallet.restore() to recover any missing proofs.",
saga_type,
saga_id
);
return None;
}
};
Some(OutputRecoveryParams {
blinded_messages,
counter_start,
counter_end,
})
}
#[instrument(skip(self))]
async fn cleanup_orphaned_quote_reservations(&self) -> Result<(), Error> {
let melt_quotes = self.localstore.get_melt_quotes().await?;
for quote in melt_quotes {
if quote.unit != self.unit {
continue;
}
if let Some(ref mint_url) = quote.mint_url {
if mint_url != &self.mint_url {
continue;
}
}
if let Some(ref operation_id_str) = quote.used_by_operation {
if let Ok(operation_id) = uuid::Uuid::parse_str(operation_id_str) {
match self.localstore.get_saga(&operation_id).await {
Ok(Some(_)) => {
}
Ok(None) => {
tracing::warn!(
"Found orphaned melt quote reservation: quote={}, operation={}. Releasing.",
quote.id,
operation_id
);
if let Err(e) = self.localstore.release_melt_quote(&operation_id).await
{
tracing::error!(
"Failed to release orphaned melt quote {}: {}",
quote.id,
e
);
}
}
Err(e) => {
tracing::warn!(
"Failed to check saga for melt quote {}: {}",
quote.id,
e
);
}
}
}
}
}
let mint_quotes = self.localstore.get_mint_quotes().await?;
for quote in mint_quotes {
if quote.mint_url != self.mint_url || quote.unit != self.unit {
continue;
}
if let Some(ref operation_id_str) = quote.used_by_operation {
if let Ok(operation_id) = uuid::Uuid::parse_str(operation_id_str) {
match self.localstore.get_saga(&operation_id).await {
Ok(Some(_)) => {
}
Ok(None) => {
tracing::warn!(
"Found orphaned mint quote reservation: quote={}, operation={}. Releasing.",
quote.id,
operation_id
);
if let Err(e) = self.localstore.release_mint_quote(&operation_id).await
{
tracing::error!(
"Failed to release orphaned mint quote {}: {}",
quote.id,
e
);
}
}
Err(e) => {
tracing::warn!(
"Failed to check saga for mint quote {}: {}",
quote.id,
e
);
}
}
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use std::sync::Arc;
use cdk_common::mint_url::MintUrl;
use cdk_common::nuts::{MeltQuoteBolt11Response, MeltQuoteState, State};
use cdk_common::wallet::{
IssueSagaState, MeltOperationData, MeltSagaState, MintOperationData, OperationData,
ReceiveOperationData, ReceiveSagaState, WalletSaga, WalletSagaState,
};
use cdk_common::Amount;
use crate::wallet::test_utils::*;
#[tokio::test]
async fn test_recover_receive_proofs_pending() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let saga_id = uuid::Uuid::new_v4();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Receive(ReceiveSagaState::ProofsPending),
Amount::from(100),
mint_url.clone(),
cdk_common::nuts::CurrencyUnit::Sat,
OperationData::Receive(ReceiveOperationData {
token: Some("cashu...".to_string()),
counter_start: None,
counter_end: None,
amount: Some(Amount::from(100)),
blinded_messages: None,
}),
);
db.add_saga(saga).await.unwrap();
let wallet = create_test_wallet(db.clone()).await;
let report = wallet.recover_incomplete_sagas().await.unwrap();
assert_eq!(report.compensated, 1);
assert!(db.get_saga(&saga_id).await.unwrap().is_none());
}
#[tokio::test]
async fn test_recover_issue_secrets_prepared() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let saga_id = uuid::Uuid::new_v4();
let mut quote = test_mint_quote(mint_url.clone());
quote.used_by_operation = Some(saga_id.to_string());
db.add_mint_quote(quote.clone()).await.unwrap();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Issue(IssueSagaState::SecretsPrepared),
Amount::from(1000),
mint_url.clone(),
cdk_common::nuts::CurrencyUnit::Sat,
OperationData::Mint(MintOperationData::new_single(
quote.id.clone(),
Amount::from(1000),
Some(0),
Some(10),
None,
)),
);
db.add_saga(saga).await.unwrap();
let wallet = create_test_wallet(db.clone()).await;
let report = wallet.recover_incomplete_sagas().await.unwrap();
assert_eq!(report.compensated, 1);
let retrieved_quote = db.get_mint_quote("e.id).await.unwrap().unwrap();
assert!(retrieved_quote.used_by_operation.is_none());
assert!(db.get_saga(&saga_id).await.unwrap().is_none());
}
#[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 proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
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 quote = test_melt_quote();
quote.used_by_operation = Some(saga_id.to_string());
db.add_melt_quote(quote.clone()).await.unwrap();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Melt(MeltSagaState::ProofsReserved),
Amount::from(100),
mint_url.clone(),
cdk_common::nuts::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 wallet = create_test_wallet(db.clone()).await;
let report = wallet.recover_incomplete_sagas().await.unwrap();
assert_eq!(report.compensated, 1);
let proofs = db
.get_proofs(None, None, Some(vec![State::Unspent]), None)
.await
.unwrap();
assert_eq!(proofs.len(), 1);
let retrieved_quote = db.get_melt_quote("e.id).await.unwrap().unwrap();
assert!(retrieved_quote.used_by_operation.is_none());
assert!(db.get_saga(&saga_id).await.unwrap().is_none());
}
#[tokio::test]
async fn test_recover_no_incomplete_sagas() {
let db = create_test_db().await;
let wallet = create_test_wallet(db.clone()).await;
let report = wallet.recover_incomplete_sagas().await.unwrap();
assert_eq!(report.recovered, 0);
assert_eq!(report.compensated, 0);
assert_eq!(report.skipped, 0);
assert_eq!(report.failed, 0);
}
#[tokio::test]
async fn test_recover_multiple_sagas() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let keyset_id = test_keyset_id();
for i in 0..3 {
let saga_id = uuid::Uuid::new_v4();
let proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
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 quote = test_melt_quote();
quote.id = format!("quote_{}", i);
quote.used_by_operation = Some(saga_id.to_string());
db.add_melt_quote(quote.clone()).await.unwrap();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Melt(MeltSagaState::ProofsReserved),
Amount::from(100),
mint_url.clone(),
cdk_common::nuts::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 wallet = create_test_wallet(db.clone()).await;
let report = wallet.recover_incomplete_sagas().await.unwrap();
assert_eq!(report.compensated, 3);
let proofs = db
.get_proofs(None, None, Some(vec![State::Unspent]), None)
.await
.unwrap();
assert_eq!(proofs.len(), 3);
let sagas = db.get_incomplete_sagas().await.unwrap();
assert!(sagas.is_empty());
}
#[tokio::test]
async fn test_cleanup_orphaned_melt_quote_reservation() {
let db = create_test_db().await;
let operation_id = uuid::Uuid::new_v4();
let mut quote = test_melt_quote();
quote.used_by_operation = Some(operation_id.to_string());
db.add_melt_quote(quote.clone()).await.unwrap();
let wallet = create_test_wallet(db.clone()).await;
let _report = wallet.recover_incomplete_sagas().await.unwrap();
let retrieved_quote = db.get_melt_quote("e.id).await.unwrap().unwrap();
assert!(retrieved_quote.used_by_operation.is_none());
}
#[tokio::test]
async fn test_cleanup_orphaned_mint_quote_reservation() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let operation_id = uuid::Uuid::new_v4();
let mut quote = test_mint_quote(mint_url);
quote.used_by_operation = Some(operation_id.to_string());
db.add_mint_quote(quote.clone()).await.unwrap();
let wallet = create_test_wallet(db.clone()).await;
let _report = wallet.recover_incomplete_sagas().await.unwrap();
let retrieved_quote = db.get_mint_quote("e.id).await.unwrap().unwrap();
assert!(retrieved_quote.used_by_operation.is_none());
}
#[tokio::test]
async fn test_recover_melt_requested_quote_failed() {
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 proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
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 quote = test_melt_quote();
quote.used_by_operation = Some(saga_id.to_string());
let quote_id = quote.id.clone();
db.add_melt_quote(quote).await.unwrap();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Melt(MeltSagaState::MeltRequested),
Amount::from(100),
mint_url.clone(),
cdk_common::nuts::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 mock_client = Arc::new(MockMintConnector::new());
mock_client.set_melt_quote_status_response(Ok(MeltQuoteBolt11Response {
quote: quote_id.clone(),
amount: Amount::from(100),
fee_reserve: Amount::from(10),
state: MeltQuoteState::Failed,
expiry: 9999999999,
payment_preimage: None,
change: None,
request: None,
unit: None,
}));
let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
let report = wallet.recover_incomplete_sagas().await.unwrap();
assert_eq!(report.compensated, 1);
assert_eq!(report.recovered, 0);
let proofs = db
.get_proofs(None, None, Some(vec![State::Unspent]), None)
.await
.unwrap();
assert_eq!(proofs.len(), 1);
let retrieved_quote = db.get_melt_quote("e_id).await.unwrap().unwrap();
assert!(retrieved_quote.used_by_operation.is_none());
assert!(db.get_saga(&saga_id).await.unwrap().is_none());
}
#[tokio::test]
async fn test_recover_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 proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
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 quote = test_melt_quote();
quote.used_by_operation = Some(saga_id.to_string());
let quote_id = quote.id.clone();
db.add_melt_quote(quote).await.unwrap();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Melt(MeltSagaState::MeltRequested),
Amount::from(100),
mint_url.clone(),
cdk_common::nuts::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 mock_client = Arc::new(MockMintConnector::new());
mock_client.set_melt_quote_status_response(Ok(MeltQuoteBolt11Response {
quote: quote_id.clone(),
amount: Amount::from(100),
fee_reserve: Amount::from(10),
state: MeltQuoteState::Pending,
expiry: 9999999999,
payment_preimage: None,
change: None,
request: None,
unit: None,
}));
let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
let report = wallet.recover_incomplete_sagas().await.unwrap();
assert_eq!(report.skipped, 1);
assert_eq!(report.compensated, 0);
assert_eq!(report.recovered, 0);
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());
}
#[tokio::test]
async fn test_recover_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 proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
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 quote = test_melt_quote();
quote.used_by_operation = Some(saga_id.to_string());
let quote_id = quote.id.clone();
db.add_melt_quote(quote).await.unwrap();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Melt(MeltSagaState::MeltRequested),
Amount::from(100),
mint_url.clone(),
cdk_common::nuts::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 mock_client = Arc::new(MockMintConnector::new());
mock_client.set_melt_quote_status_response(Ok(MeltQuoteBolt11Response {
quote: quote_id.clone(),
amount: Amount::from(100),
fee_reserve: Amount::from(10),
state: MeltQuoteState::Unpaid,
expiry: 9999999999,
payment_preimage: None,
change: None,
request: None,
unit: None,
}));
let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
let report = wallet.recover_incomplete_sagas().await.unwrap();
assert_eq!(report.compensated, 1);
assert_eq!(report.skipped, 0);
assert_eq!(report.recovered, 0);
let proofs = db
.get_proofs(None, None, Some(vec![State::Unspent]), None)
.await
.unwrap();
assert_eq!(proofs.len(), 1);
let retrieved_quote = db.get_melt_quote("e_id).await.unwrap().unwrap();
assert!(retrieved_quote.used_by_operation.is_none());
assert!(db.get_saga(&saga_id).await.unwrap().is_none());
}
#[tokio::test]
async fn test_recover_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 proof_info = test_proof_info(keyset_id, 100, mint_url.clone());
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 quote = test_melt_quote();
quote.used_by_operation = Some(saga_id.to_string());
let quote_id = quote.id.clone();
db.add_melt_quote(quote).await.unwrap();
let saga = WalletSaga::new(
saga_id,
WalletSagaState::Melt(MeltSagaState::MeltRequested),
Amount::from(100),
mint_url.clone(),
cdk_common::nuts::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 mock_client = Arc::new(MockMintConnector::new());
mock_client.set_melt_quote_status_response(Ok(MeltQuoteBolt11Response {
quote: quote_id.clone(),
amount: Amount::from(100),
fee_reserve: Amount::from(10),
state: MeltQuoteState::Paid,
expiry: 9999999999,
payment_preimage: Some("preimage123".to_string()),
change: None,
request: None,
unit: None,
}));
let wallet = create_test_wallet_with_mock(db.clone(), mock_client).await;
let report = wallet.recover_incomplete_sagas().await.unwrap();
assert_eq!(report.recovered, 1);
assert_eq!(report.compensated, 0);
assert_eq!(report.skipped, 0);
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_incomplete_sagas_filters_by_mint_and_unit() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let other_mint_url = MintUrl::from_str("https://other-mint.example.com").unwrap();
let saga_id_1 = uuid::Uuid::new_v4();
let saga_id_2 = uuid::Uuid::new_v4();
let saga_id_3 = uuid::Uuid::new_v4();
let saga_1 = WalletSaga::new(
saga_id_1,
WalletSagaState::Receive(ReceiveSagaState::ProofsPending),
Amount::from(100),
mint_url.clone(),
cdk_common::nuts::CurrencyUnit::Sat,
OperationData::Receive(ReceiveOperationData {
token: Some("cashu...".to_string()),
counter_start: None,
counter_end: None,
amount: Some(Amount::from(100)),
blinded_messages: None,
}),
);
db.add_saga(saga_1).await.unwrap();
let saga_2 = WalletSaga::new(
saga_id_2,
WalletSagaState::Receive(ReceiveSagaState::ProofsPending),
Amount::from(100),
other_mint_url.clone(),
cdk_common::nuts::CurrencyUnit::Sat,
OperationData::Receive(ReceiveOperationData {
token: Some("cashu...".to_string()),
counter_start: None,
counter_end: None,
amount: Some(Amount::from(100)),
blinded_messages: None,
}),
);
db.add_saga(saga_2).await.unwrap();
let saga_3 = WalletSaga::new(
saga_id_3,
WalletSagaState::Receive(ReceiveSagaState::ProofsPending),
Amount::from(100),
mint_url.clone(),
cdk_common::nuts::CurrencyUnit::Usd,
OperationData::Receive(ReceiveOperationData {
token: Some("cashu...".to_string()),
counter_start: None,
counter_end: None,
amount: Some(Amount::from(100)),
blinded_messages: None,
}),
);
db.add_saga(saga_3).await.unwrap();
let wallet = create_test_wallet(db.clone()).await;
let report = wallet.recover_incomplete_sagas().await.unwrap();
assert_eq!(report.compensated, 1);
assert_eq!(report.recovered, 0);
assert_eq!(report.skipped, 0);
assert!(db.get_saga(&saga_id_1).await.unwrap().is_none());
assert!(db.get_saga(&saga_id_2).await.unwrap().is_some());
assert!(db.get_saga(&saga_id_3).await.unwrap().is_some());
}
#[tokio::test]
async fn test_cleanup_orphaned_quote_reservations_filters_by_mint_and_unit() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let other_mint_url = MintUrl::from_str("https://other-mint.example.com").unwrap();
let mut melt_quote_1 = test_melt_quote();
melt_quote_1.unit = cdk_common::nuts::CurrencyUnit::Sat;
melt_quote_1.used_by_operation = Some(uuid::Uuid::new_v4().to_string());
let melt_quote_id_1 = melt_quote_1.id.clone();
db.add_melt_quote(melt_quote_1).await.unwrap();
let mut melt_quote_2 = test_melt_quote();
melt_quote_2.mint_url = Some(other_mint_url.clone());
melt_quote_2.unit = cdk_common::nuts::CurrencyUnit::Sat;
melt_quote_2.used_by_operation = Some(uuid::Uuid::new_v4().to_string());
let melt_quote_id_2 = melt_quote_2.id.clone();
db.add_melt_quote(melt_quote_2).await.unwrap();
let mut mint_quote_1 = test_mint_quote(mint_url.clone());
mint_quote_1.unit = cdk_common::nuts::CurrencyUnit::Sat;
mint_quote_1.used_by_operation = Some(uuid::Uuid::new_v4().to_string());
let mint_quote_id_1 = mint_quote_1.id.clone();
db.add_mint_quote(mint_quote_1).await.unwrap();
let mut mint_quote_2 = test_mint_quote(other_mint_url.clone());
mint_quote_2.unit = cdk_common::nuts::CurrencyUnit::Sat;
mint_quote_2.used_by_operation = Some(uuid::Uuid::new_v4().to_string());
let mint_quote_id_2_mint = mint_quote_2.id.clone();
db.add_mint_quote(mint_quote_2).await.unwrap();
let mut mint_quote_3 = test_mint_quote(mint_url.clone());
mint_quote_3.unit = cdk_common::nuts::CurrencyUnit::Usd;
mint_quote_3.used_by_operation = Some(uuid::Uuid::new_v4().to_string());
let mint_quote_id_3 = mint_quote_3.id.clone();
db.add_mint_quote(mint_quote_3).await.unwrap();
let wallet = create_test_wallet(db.clone()).await;
wallet.cleanup_orphaned_quote_reservations().await.unwrap();
assert!(db
.get_melt_quote(&melt_quote_id_1)
.await
.unwrap()
.unwrap()
.used_by_operation
.is_none());
assert!(db
.get_mint_quote(&mint_quote_id_1)
.await
.unwrap()
.unwrap()
.used_by_operation
.is_none());
assert!(db
.get_melt_quote(&melt_quote_id_2)
.await
.unwrap()
.unwrap()
.used_by_operation
.is_some());
assert!(db
.get_mint_quote(&mint_quote_id_2_mint)
.await
.unwrap()
.unwrap()
.used_by_operation
.is_some());
assert!(db
.get_mint_quote(&mint_quote_id_3)
.await
.unwrap()
.unwrap()
.used_by_operation
.is_some());
}
}