use std::sync::Arc;
use async_trait::async_trait;
use cdk_common::database::{self, WalletDatabase};
use tracing::instrument;
use uuid::Uuid;
use crate::wallet::saga::CompensatingAction;
pub(crate) use crate::wallet::saga::RevertProofReservation;
use crate::Error;
pub struct ReleaseMeltQuote {
pub localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync>,
pub operation_id: Uuid,
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl CompensatingAction for ReleaseMeltQuote {
#[instrument(skip_all)]
async fn execute(&self) -> Result<(), Error> {
tracing::info!(
"Compensation: Releasing melt quote reserved by operation {}",
self.operation_id
);
self.localstore
.release_melt_quote(&self.operation_id)
.await
.map_err(Error::Database)?;
Ok(())
}
fn name(&self) -> &'static str {
"ReleaseMeltQuote"
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use cdk_common::nut00::KnownMethod;
use cdk_common::nuts::{CurrencyUnit, MeltQuoteState, State};
use cdk_common::wallet::{
MeltQuote, OperationData, SwapOperationData, SwapSagaState, WalletSaga, WalletSagaState,
};
use cdk_common::{Amount, PaymentMethod};
use super::*;
use crate::wallet::saga::test_utils::*;
use crate::wallet::saga::CompensatingAction;
fn test_melt_saga(mint_url: cdk_common::mint_url::MintUrl) -> WalletSaga {
WalletSaga::new(
uuid::Uuid::new_v4(),
WalletSagaState::Swap(SwapSagaState::ProofsReserved),
Amount::from(1000),
mint_url,
CurrencyUnit::Sat,
OperationData::Swap(SwapOperationData {
input_amount: Amount::from(1000),
output_amount: Amount::from(990),
counter_start: Some(0),
counter_end: Some(10),
blinded_messages: None,
}),
)
}
fn test_melt_quote() -> MeltQuote {
MeltQuote {
id: format!("test_melt_quote_{}", uuid::Uuid::new_v4()),
mint_url: Some(
cdk_common::mint_url::MintUrl::from_str("https://test-mint.example.com").unwrap(),
),
unit: CurrencyUnit::Sat,
amount: Amount::from(1000),
request: "lnbc1000...".to_string(),
fee_reserve: Amount::from(10),
state: MeltQuoteState::Unpaid,
expiry: 9999999999,
payment_preimage: None,
payment_method: PaymentMethod::Known(KnownMethod::Bolt11),
used_by_operation: None,
version: 0,
}
}
#[tokio::test]
async fn test_revert_proof_reservation_is_idempotent() {
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, 100, mint_url.clone(), State::Reserved);
let proof_y = proof_info.y;
db.update_proofs(vec![proof_info], vec![]).await.unwrap();
let saga = test_melt_saga(mint_url);
let saga_id = saga.id;
db.add_saga(saga).await.unwrap();
let compensation = RevertProofReservation {
localstore: db.clone(),
proof_ys: vec![proof_y],
saga_id,
};
compensation.execute().await.unwrap();
compensation.execute().await.unwrap();
let proofs = db
.get_proofs(None, None, Some(vec![State::Unspent]), None)
.await
.unwrap();
assert_eq!(proofs.len(), 1);
}
#[tokio::test]
async fn test_revert_proof_reservation_handles_missing_saga() {
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, 100, mint_url.clone(), State::Reserved);
let proof_y = proof_info.y;
db.update_proofs(vec![proof_info], vec![]).await.unwrap();
let saga_id = uuid::Uuid::new_v4();
let compensation = RevertProofReservation {
localstore: db.clone(),
proof_ys: vec![proof_y],
saga_id,
};
compensation.execute().await.unwrap();
let proofs = db
.get_proofs(None, None, Some(vec![State::Unspent]), None)
.await
.unwrap();
assert_eq!(proofs.len(), 1);
}
#[tokio::test]
async fn test_release_melt_quote_is_idempotent() {
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 compensation = ReleaseMeltQuote {
localstore: db.clone(),
operation_id,
};
compensation.execute().await.unwrap();
compensation.execute().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_release_melt_quote_handles_no_matching_quote() {
let db = create_test_db().await;
let operation_id = uuid::Uuid::new_v4();
let compensation = ReleaseMeltQuote {
localstore: db.clone(),
operation_id,
};
let result = compensation.execute().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_compensation_only_affects_specified_proofs() {
let db = create_test_db().await;
let mint_url = test_mint_url();
let keyset_id = test_keyset_id();
let proof_info_1 = test_proof_info(keyset_id, 100, mint_url.clone(), State::Reserved);
let proof_info_2 = test_proof_info(keyset_id, 200, mint_url.clone(), State::Reserved);
let proof_y_1 = proof_info_1.y;
let proof_y_2 = proof_info_2.y;
db.update_proofs(vec![proof_info_1, proof_info_2], vec![])
.await
.unwrap();
let saga = test_melt_saga(mint_url);
let saga_id = saga.id;
db.add_saga(saga).await.unwrap();
let compensation = RevertProofReservation {
localstore: db.clone(),
proof_ys: vec![proof_y_1],
saga_id,
};
compensation.execute().await.unwrap();
let unspent = db
.get_proofs(None, None, Some(vec![State::Unspent]), None)
.await
.unwrap();
assert_eq!(unspent.len(), 1);
assert_eq!(unspent[0].y, proof_y_1);
let reserved = db
.get_proofs(None, None, Some(vec![State::Reserved]), None)
.await
.unwrap();
assert_eq!(reserved.len(), 1);
assert_eq!(reserved[0].y, proof_y_2);
}
}