use std::str::FromStr;
use cdk_common::mint::{MeltSagaState, OperationKind, Saga};
use cdk_common::nut00::KnownMethod;
use cdk_common::nuts::MeltQuoteState;
use cdk_common::{Amount, PaymentMethod, ProofsMethods, State};
use crate::mint::melt::melt_saga::MeltSaga;
use crate::test_helpers::mint::{create_test_mint, mint_test_proofs};
#[tokio::test]
async fn test_melt_saga_initial_state_creation() {
let mint = create_test_mint().await.unwrap();
let db = mint.localstore();
let pubsub = mint.pubsub_manager();
let _saga = MeltSaga::new(std::sync::Arc::new(mint.clone()), db, pubsub);
}
#[tokio::test]
async fn test_saga_state_persistence_after_setup() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let sagas = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
let persisted_saga = sagas
.iter()
.find(|s| s.operation_id == operation_id)
.expect("Saga should be persisted");
assert_eq!(
persisted_saga.operation_id, operation_id,
"Operation ID should match"
);
assert_eq!(
persisted_saga.operation_kind,
OperationKind::Melt,
"Operation kind should be Melt"
);
match &persisted_saga.state {
cdk_common::mint::SagaStateEnum::Melt(state) => {
assert_eq!(
*state,
MeltSagaState::SetupComplete,
"State should be SetupComplete"
);
}
_ => panic!("Expected Melt saga state"),
}
let input_ys = proofs.ys().unwrap();
let stored_input_ys = mint
.localstore
.get_proof_ys_by_operation_id(&persisted_saga.operation_id)
.await
.unwrap();
assert_eq!(
stored_input_ys.len(),
input_ys.len(),
"Should store all input Ys"
);
for y in &input_ys {
assert!(
stored_input_ys.contains(y),
"Input Y should be stored: {:?}",
y
);
}
assert!(
persisted_saga.created_at > 0,
"Created timestamp should be set"
);
assert!(
persisted_saga.updated_at > 0,
"Updated timestamp should be set"
);
assert_eq!(
persisted_saga.created_at, persisted_saga.updated_at,
"Timestamps should match for new saga"
);
let stored_blinded_secrets = mint
.localstore
.get_blinded_secrets_by_operation_id(&persisted_saga.operation_id)
.await
.unwrap();
assert!(
stored_blinded_secrets.is_empty(),
"Melt saga without change should have no blinded_secrets"
);
}
#[tokio::test]
async fn test_saga_deletion_on_success() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
assert_saga_exists(&mint, &operation_id).await;
let (payment_saga, decision) = setup_saga
.attempt_internal_settlement(&melt_request)
.await
.unwrap();
let confirmed_saga = payment_saga.make_payment(decision).await.unwrap();
let _response = confirmed_saga.finalize().await.unwrap();
assert_saga_not_exists(&mint, &operation_id).await;
let sagas = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
assert!(sagas.is_empty(), "Should have no incomplete melt sagas");
}
#[tokio::test]
async fn test_saga_persists_on_finalize_failure() {
}
#[tokio::test]
async fn test_crash_recovery_setup_complete() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.expect("Setup should succeed");
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
let operation_id = *setup_saga.state_data.operation.id();
assert_saga_exists(&mint, &operation_id).await;
drop(setup_saga);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed");
assert_proofs_state(&mint, &input_ys, None).await;
assert_saga_not_exists(&mint, &operation_id).await;
let recovered_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("Quote should still exist");
assert_eq!(
recovered_quote.state,
MeltQuoteState::Unpaid,
"Quote state should be reset to Unpaid after recovery"
);
}
#[tokio::test]
async fn test_crash_recovery_multiple_sagas() {
let mint = create_test_mint().await.unwrap();
let mut operation_ids = Vec::new();
let mut proof_ys_list = Vec::new();
let mut quote_ids = Vec::new();
for i in 0..5 {
let proofs = mint_test_proofs(&mint, Amount::from(5_000 + i * 100))
.await
.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(4_000 + i * 100)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
operation_ids.push(*setup_saga.state_data.operation.id());
proof_ys_list.push(input_ys);
quote_ids.push(quote.id.clone());
drop(setup_saga);
}
let sagas_before = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
assert_eq!(
sagas_before.len(),
5,
"Should have 5 incomplete sagas before recovery"
);
for operation_id in &operation_ids {
assert!(
sagas_before.iter().any(|s| s.operation_id == *operation_id),
"Saga {} should exist before recovery",
operation_id
);
}
for input_ys in &proof_ys_list {
assert_proofs_state(&mint, input_ys, Some(State::Pending)).await;
}
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed");
let sagas_after = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
assert!(
sagas_after.is_empty(),
"All sagas should be deleted after recovery"
);
for operation_id in &operation_ids {
assert_saga_not_exists(&mint, operation_id).await;
}
for input_ys in &proof_ys_list {
assert_proofs_state(&mint, input_ys, None).await;
}
for quote_id in "e_ids {
let recovered_quote = mint
.localstore
.get_melt_quote(quote_id)
.await
.unwrap()
.expect("Quote should still exist");
assert_eq!(
recovered_quote.state,
MeltQuoteState::Unpaid,
"Quote {} should be reset to Unpaid",
quote_id
);
}
}
#[tokio::test]
async fn test_crash_recovery_orphaned_saga() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let input_ys = proofs.ys().unwrap();
drop(setup_saga);
assert_saga_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed");
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, None).await;
let recovered_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
assert_eq!(recovered_quote.state, MeltQuoteState::Unpaid);
}
#[tokio::test]
async fn test_crash_recovery_partial_failure() {
}
#[tokio::test]
async fn test_crash_recovery_internal_settlement() {
use cdk_common::nuts::MintQuoteState;
use cdk_common::MintQuoteBolt11Request;
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let mint_quote_response: cdk_common::MintQuoteBolt11Response<_> = mint
.get_mint_quote(
MintQuoteBolt11Request {
amount: Amount::from(4_000),
unit: cdk_common::CurrencyUnit::Sat,
description: None,
pubkey: None,
}
.into(),
)
.await
.unwrap()
.into();
let mint_quote_id = cdk_common::QuoteId::from_str(&mint_quote_response.quote).unwrap();
let mint_quote = mint
.localstore
.get_mint_quote(&mint_quote_id)
.await
.unwrap()
.expect("Mint quote should exist");
use cdk_common::melt::MeltQuoteRequest;
use cdk_common::nuts::MeltQuoteBolt11Request;
let melt_bolt11_request = MeltQuoteBolt11Request {
request: mint_quote.request.to_string().parse().unwrap(),
unit: cdk_common::CurrencyUnit::Sat,
options: None,
};
let melt_quote_request = MeltQuoteRequest::Bolt11(melt_bolt11_request);
let melt_quote_response = mint.get_melt_quote(melt_quote_request).await.unwrap();
let melt_quote = mint
.localstore
.get_melt_quote(&melt_quote_response.quote)
.await
.unwrap()
.expect("Melt quote should exist");
let melt_request = create_test_melt_request(&proofs, &melt_quote);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let (payment_saga, decision) = setup_saga
.attempt_internal_settlement(&melt_request)
.await
.unwrap();
match decision {
crate::mint::melt::melt_saga::state::SettlementDecision::Internal { amount } => {
assert_eq!(
amount,
Amount::from(4_000).with_unit(cdk_common::CurrencyUnit::Sat),
"Internal settlement amount should match"
);
}
_ => panic!("Expected internal settlement decision"),
}
drop(payment_saga);
let persisted_saga = assert_saga_exists(&mint, &operation_id).await;
match &persisted_saga.state {
cdk_common::mint::SagaStateEnum::Melt(state) => {
assert_eq!(
*state,
MeltSagaState::PaymentAttempted,
"Saga should be in PaymentAttempted state after internal settlement"
);
}
_ => panic!("Expected Melt saga state"),
}
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
let mint_quote_after = mint
.localstore
.get_mint_quote(&mint_quote_id)
.await
.unwrap()
.expect("Mint quote should exist");
assert_eq!(
mint_quote_after.state(),
MintQuoteState::Paid,
"Mint quote should be paid after internal settlement"
);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed");
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Spent)).await;
let melt_quote_after = mint
.localstore
.get_melt_quote(&melt_quote.id)
.await
.unwrap()
.expect("Melt quote should exist");
assert_eq!(
melt_quote_after.state,
MeltQuoteState::Paid,
"Melt quote should be paid after recovery"
);
let mint_quote_final = mint
.localstore
.get_mint_quote(&mint_quote_id)
.await
.unwrap()
.expect("Mint quote should exist");
assert_eq!(
mint_quote_final.state(),
MintQuoteState::Paid,
"Mint quote should remain paid after recovery"
);
}
#[tokio::test]
async fn test_startup_recovery_integration() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let input_ys = proofs.ys().unwrap();
drop(setup_saga);
assert_saga_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed");
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, None).await;
let new_proofs = mint_test_proofs(&mint, Amount::from(5_000)).await.unwrap();
let new_quote = create_test_melt_quote(&mint, Amount::from(4_000)).await;
let new_request = create_test_melt_request(&new_proofs, &new_quote);
let new_verification = mint.verify_inputs(new_request.inputs()).await.unwrap();
let new_saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let _new_setup = new_saga
.setup_melt(
&new_request,
new_verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_startup_resilient_to_recovery_errors() {
}
#[tokio::test]
async fn test_compensation_removes_proofs() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
drop(setup_saga);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed");
assert_proofs_state(&mint, &input_ys, None).await;
assert_saga_not_exists(&mint, &operation_id).await;
let new_quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let new_request = create_test_melt_request(&proofs, &new_quote);
let new_verification = mint.verify_inputs(new_request.inputs()).await.unwrap();
let new_saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let new_setup = new_saga
.setup_melt(
&new_request,
new_verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.expect("Should be able to reuse proofs after compensation");
assert_saga_exists(&mint, new_setup.state_data.operation.id()).await;
}
#[tokio::test]
async fn test_compensation_removes_change_outputs() {
use cdk_common::nuts::MeltRequest;
use crate::test_helpers::mint::create_test_blinded_messages;
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(7_000)).await;
let (blinded_messages, _premint) = create_test_blinded_messages(&mint, Amount::from(3_000))
.await
.unwrap();
let blinded_secrets: Vec<_> = blinded_messages
.iter()
.map(|bm| bm.blinded_secret)
.collect();
let melt_request = MeltRequest::new(quote.id.clone(), proofs.clone(), Some(blinded_messages));
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let stored_info = {
let mut tx = mint.localstore.begin_transaction().await.unwrap();
let info = tx
.get_melt_request_and_blinded_messages("e.id)
.await
.expect("Should be able to query melt request")
.expect("Melt request should exist");
tx.rollback().await.unwrap();
info
};
assert_eq!(
stored_info.change_outputs.len(),
blinded_secrets.len(),
"All blinded messages should be stored"
);
drop(setup_saga);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed");
let result = {
let mut tx = mint.localstore.begin_transaction().await.unwrap();
let res = tx
.get_melt_request_and_blinded_messages("e.id)
.await
.expect("Query should succeed");
tx.rollback().await.unwrap();
res
};
assert!(
result.is_none(),
"Melt request and blinded messages should be deleted after compensation"
);
assert_saga_not_exists(&mint, &operation_id).await;
}
#[tokio::test]
async fn test_compensation_resets_quote_state() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
assert_eq!(
quote.state,
MeltQuoteState::Unpaid,
"Quote should start as Unpaid"
);
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let pending_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("Quote should exist");
assert_eq!(
pending_quote.state,
MeltQuoteState::Pending,
"Quote state should be Pending after setup"
);
drop(setup_saga);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed");
let recovered_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("Quote should still exist after compensation");
assert_eq!(
recovered_quote.state,
MeltQuoteState::Unpaid,
"Quote state should be reset to Unpaid after compensation"
);
assert_saga_not_exists(&mint, &operation_id).await;
let new_proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let new_request = create_test_melt_request(&new_proofs, &recovered_quote);
let new_verification = mint.verify_inputs(new_request.inputs()).await.unwrap();
let new_saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let _new_setup = new_saga
.setup_melt(
&new_request,
new_verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.expect("Should be able to reuse quote after compensation");
}
#[tokio::test]
async fn test_compensation_idempotent() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
assert_saga_exists(&mint, &operation_id).await;
drop(setup_saga);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("First recovery should succeed");
assert_proofs_state(&mint, &input_ys, None).await;
assert_saga_not_exists(&mint, &operation_id).await;
let quote_after_first = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("Quote should exist");
assert_eq!(quote_after_first.state, MeltQuoteState::Unpaid);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Second recovery should succeed without errors");
assert_proofs_state(&mint, &input_ys, None).await;
assert_saga_not_exists(&mint, &operation_id).await;
let quote_after_second = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("Quote should still exist");
assert_eq!(quote_after_second.state, MeltQuoteState::Unpaid);
assert_eq!(
quote_after_first.state, quote_after_second.state,
"Quote state should be identical after multiple compensations"
);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Third recovery should also succeed");
}
#[tokio::test]
async fn test_saga_deleted_after_payment_failure() {
use cdk_common::CurrencyUnit;
use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let fake_description = FakeInvoiceDescription {
pay_invoice_state: MeltQuoteState::Failed, check_payment_state: MeltQuoteState::Failed, pay_err: false,
check_err: false,
};
let amount_msats: u64 = Amount::from(9_000).into();
let invoice = create_fake_invoice(
amount_msats,
serde_json::to_string(&fake_description).unwrap(),
);
let bolt11_request = cdk_common::nuts::MeltQuoteBolt11Request {
request: invoice,
unit: CurrencyUnit::Sat,
options: None,
};
let request = cdk_common::melt::MeltQuoteRequest::Bolt11(bolt11_request);
let quote_response = mint.get_melt_quote(request).await.unwrap();
let quote = mint
.localstore
.get_melt_quote("e_response.quote)
.await
.unwrap()
.expect("Quote should exist");
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = setup_saga.operation_id;
assert_saga_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
let (payment_saga, decision) = setup_saga
.attempt_internal_settlement(&melt_request)
.await
.unwrap();
let result = payment_saga.make_payment(decision).await;
assert!(
result.is_err(),
"Payment should fail with Failed status from FakeWallet"
);
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, None).await;
let recovered_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("Quote should still exist");
assert_eq!(
recovered_quote.state,
MeltQuoteState::Unpaid,
"Quote state should be reset to Unpaid after compensation"
);
}
#[tokio::test]
async fn test_saga_content_validation() {
let mint = create_test_mint().await.unwrap();
let proof_amount = Amount::from(10_000);
let proofs = mint_test_proofs(&mint, proof_amount).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote_amount = Amount::from(9_000);
let quote = create_test_melt_quote(&mint, quote_amount).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let persisted_saga = assert_saga_exists(&mint, &operation_id).await;
assert_eq!(
persisted_saga.operation_id, operation_id,
"Operation ID should match exactly"
);
assert_eq!(
persisted_saga.operation_kind,
OperationKind::Melt,
"Operation kind must be Melt"
);
match &persisted_saga.state {
cdk_common::mint::SagaStateEnum::Melt(state) => {
assert_eq!(
*state,
MeltSagaState::SetupComplete,
"State should be SetupComplete after setup"
);
}
_ => panic!("Expected Melt saga state, got {:?}", persisted_saga.state),
}
let stored_input_ys = mint
.localstore
.get_proof_ys_by_operation_id(&persisted_saga.operation_id)
.await
.unwrap();
assert_eq!(
stored_input_ys.len(),
input_ys.len(),
"Should store all input Ys"
);
for (i, expected_y) in input_ys.iter().enumerate() {
assert!(
stored_input_ys.contains(expected_y),
"Input Y at index {} should be stored: {:?}",
i,
expected_y
);
}
let current_timestamp = web_time::SystemTime::now()
.duration_since(web_time::UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(
persisted_saga.created_at > 0,
"Created timestamp should be set"
);
assert!(
persisted_saga.updated_at > 0,
"Updated timestamp should be set"
);
assert!(
persisted_saga.created_at <= current_timestamp,
"Created timestamp should not be in the future"
);
assert!(
persisted_saga.created_at > current_timestamp - 3600,
"Created timestamp should be recent (within last hour)"
);
assert_eq!(
persisted_saga.created_at, persisted_saga.updated_at,
"Timestamps should match for newly created saga"
);
let stored_blinded_secrets = mint
.localstore
.get_blinded_secrets_by_operation_id(&persisted_saga.operation_id)
.await
.unwrap();
assert!(
stored_blinded_secrets.is_empty(),
"Melt saga without change should have no blinded_secrets"
);
}
#[tokio::test]
async fn test_saga_state_updates_timestamp() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let saga1 = assert_saga_exists(&mint, &operation_id).await;
let created_at_1 = saga1.created_at;
let updated_at_1 = saga1.updated_at;
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
let saga2 = assert_saga_exists(&mint, &operation_id).await;
let created_at_2 = saga2.created_at;
let updated_at_2 = saga2.updated_at;
assert_eq!(
created_at_1, created_at_2,
"Created timestamp should not change across retrievals"
);
assert_eq!(
updated_at_1, updated_at_2,
"Updated timestamp should not change across retrievals"
);
assert_eq!(
created_at_1, updated_at_1,
"New saga should have matching created_at and updated_at"
);
}
#[tokio::test]
async fn test_get_incomplete_sagas_filters_by_kind() {
use crate::mint::swap::swap_saga::SwapSaga;
use crate::test_helpers::mint::create_test_blinded_messages;
let mint = create_test_mint().await.unwrap();
let melt_proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let melt_quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&melt_proofs, &melt_quote);
let melt_verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let melt_saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let melt_setup = melt_saga
.setup_melt(
&melt_request,
melt_verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let melt_operation_id = *melt_setup.state_data.operation.id();
let swap_proofs = mint_test_proofs(&mint, Amount::from(5_000)).await.unwrap();
let swap_verification = crate::mint::Verification {
amount: Amount::from(5_000).with_unit(cdk_common::nuts::CurrencyUnit::Sat),
};
let (swap_outputs, _) = create_test_blinded_messages(&mint, Amount::from(5_000))
.await
.unwrap();
let swap_saga = SwapSaga::new(&mint, mint.localstore(), mint.pubsub_manager());
let _swap_setup = swap_saga
.setup_swap(&swap_proofs, &swap_outputs, None, swap_verification)
.await
.unwrap();
let melt_sagas = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
assert_eq!(melt_sagas.len(), 1, "Should return exactly one melt saga");
assert_eq!(
melt_sagas[0].operation_id, melt_operation_id,
"Returned saga should be the melt saga"
);
assert_eq!(
melt_sagas[0].operation_kind,
OperationKind::Melt,
"Returned saga should have Melt kind"
);
let swap_sagas = mint
.localstore
.get_incomplete_sagas(OperationKind::Swap)
.await
.unwrap();
assert_eq!(swap_sagas.len(), 1, "Should return exactly one swap saga");
assert_eq!(
swap_sagas[0].operation_kind,
OperationKind::Swap,
"Returned saga should have Swap kind"
);
}
#[tokio::test]
async fn test_get_incomplete_sagas_empty() {
let mint = create_test_mint().await.unwrap();
let sagas = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
assert!(sagas.is_empty(), "Should have no incomplete melt sagas");
}
#[tokio::test]
async fn test_concurrent_melt_operations() {
let mint = create_test_mint().await.unwrap();
let mut tasks = Vec::new();
for _ in 0..5 {
let mint_clone = mint.clone();
let task = tokio::spawn(async move {
let proofs = mint_test_proofs(&mint_clone, Amount::from(10_000))
.await
.unwrap();
let quote = create_test_melt_quote(&mint_clone, Amount::from(9_000)).await;
(proofs, quote)
});
tasks.push(task);
}
let proof_quote_pairs: Vec<_> = futures::future::join_all(tasks)
.await
.into_iter()
.map(|r| r.unwrap())
.collect();
let mut setup_tasks = Vec::new();
for (proofs, quote) in proof_quote_pairs {
let mint_clone = mint.clone();
let task = tokio::spawn(async move {
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint_clone
.verify_inputs(melt_request.inputs())
.await
.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint_clone.clone()),
mint_clone.localstore(),
mint_clone.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
drop(setup_saga);
operation_id
});
setup_tasks.push(task);
}
let operation_ids: Vec<_> = futures::future::join_all(setup_tasks)
.await
.into_iter()
.map(|r| r.unwrap())
.collect();
let unique_ids: std::collections::HashSet<_> = operation_ids.iter().collect();
assert_eq!(
unique_ids.len(),
operation_ids.len(),
"All operation IDs should be unique"
);
let sagas = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
assert!(sagas.len() >= 5, "Should have at least 5 incomplete sagas");
for operation_id in &operation_ids {
assert!(
sagas.iter().any(|s| s.operation_id == *operation_id),
"Saga {} should exist in database",
operation_id
);
}
}
#[tokio::test]
async fn test_concurrent_recovery_and_operations() {
let mint = create_test_mint().await.unwrap();
let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote1 = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request1 = create_test_melt_request(&proofs1, "e1);
let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
let saga1 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga1 = saga1
.setup_melt(
&melt_request1,
verification1,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let incomplete_operation_id = *setup_saga1.state_data.operation.id();
drop(setup_saga1);
assert_saga_exists(&mint, &incomplete_operation_id).await;
let mint_for_recovery = mint.clone();
let recovery_task = tokio::spawn(async move {
mint_for_recovery
.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed")
});
let mint_for_new_op = mint.clone();
let new_operation_task = tokio::spawn(async move {
let proofs2 = mint_test_proofs(&mint_for_new_op, Amount::from(10_000))
.await
.unwrap();
let quote2 = create_test_melt_quote(&mint_for_new_op, Amount::from(9_000)).await;
let melt_request2 = create_test_melt_request(&proofs2, "e2);
let verification2 = mint_for_new_op
.verify_inputs(melt_request2.inputs())
.await
.unwrap();
let saga2 = MeltSaga::new(
std::sync::Arc::new(mint_for_new_op.clone()),
mint_for_new_op.localstore(),
mint_for_new_op.pubsub_manager(),
);
let setup_saga2 = saga2
.setup_melt(
&melt_request2,
verification2,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
*setup_saga2.state_data.operation.id()
});
let (recovery_result, new_op_result) = tokio::join!(recovery_task, new_operation_task);
recovery_result.expect("Recovery task should complete");
let new_operation_id = new_op_result.expect("New operation task should complete");
assert_saga_not_exists(&mint, &incomplete_operation_id).await;
assert_saga_exists(&mint, &new_operation_id).await;
}
#[tokio::test]
async fn test_double_spend_detection() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote1 = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request1 = create_test_melt_request(&proofs, "e1);
let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
let saga1 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let _setup_saga1 = saga1
.setup_melt(
&melt_request1,
verification1,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let input_ys = proofs.ys().unwrap();
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
let quote2 = create_test_melt_quote(&mint, Amount::from(8_000)).await;
let melt_request2 = create_test_melt_request(&proofs, "e2);
let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap();
let saga2 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_result2 = saga2
.setup_melt(
&melt_request2,
verification2,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await;
assert!(
setup_result2.is_err(),
"Second melt with same proofs should fail during setup"
);
if let Err(error) = setup_result2 {
let error_msg = error.to_string().to_lowercase();
assert!(
error_msg.contains("pending")
|| error_msg.contains("spent")
|| error_msg.contains("state"),
"Error should mention proof state issue, got: {}",
error
);
}
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
}
#[tokio::test]
async fn test_insufficient_funds() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_result = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await;
assert!(
setup_result.is_ok(),
"Setup should succeed with sufficient funds"
);
drop(setup_result);
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
}
#[tokio::test]
async fn test_invalid_quote_id() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
use cdk_common::nuts::MeltRequest;
use cdk_common::QuoteId;
let fake_quote_id = QuoteId::new_uuid();
let melt_request = MeltRequest::new(fake_quote_id.clone(), proofs.clone(), None);
let verification_result = mint.verify_inputs(melt_request.inputs()).await;
if let Ok(verification) = verification_result {
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_result = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await;
assert!(
setup_result.is_err(),
"Setup should fail with invalid quote ID"
);
if let Err(error) = setup_result {
let error_msg = error.to_string().to_lowercase();
assert!(
error_msg.contains("quote")
|| error_msg.contains("unknown")
|| error_msg.contains("not found"),
"Error should mention quote issue, got: {}",
error
);
}
} else {
eprintln!("Note: Verification failed (expected in some environments)");
}
}
#[tokio::test]
async fn test_quote_already_paid() {
let mint = create_test_mint().await.unwrap();
let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request1 = create_test_melt_request(&proofs1, "e);
let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
let saga1 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga1 = saga1
.setup_melt(
&melt_request1,
verification1,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let (payment_saga, decision) = setup_saga1
.attempt_internal_settlement(&melt_request1)
.await
.unwrap();
let confirmed_saga = payment_saga.make_payment(decision).await.unwrap();
let _response = confirmed_saga.finalize().await.unwrap();
let paid_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
assert_eq!(
paid_quote.state,
MeltQuoteState::Paid,
"Quote should be paid"
);
let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let melt_request2 = create_test_melt_request(&proofs2, &paid_quote);
let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap();
let saga2 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_result2 = saga2
.setup_melt(
&melt_request2,
verification2,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await;
assert!(
setup_result2.is_err(),
"Setup should fail with already paid quote"
);
if let Err(error) = setup_result2 {
let error_msg = error.to_string().to_lowercase();
assert!(
error_msg.contains("paid")
|| error_msg.contains("quote")
|| error_msg.contains("state"),
"Error should mention paid quote, got: {}",
error
);
}
}
#[tokio::test]
async fn test_quote_already_pending() {
let mint = create_test_mint().await.unwrap();
let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request1 = create_test_melt_request(&proofs1, "e);
let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
let saga1 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let _setup_saga1 = saga1
.setup_melt(
&melt_request1,
verification1,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let pending_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
assert_eq!(
pending_quote.state,
MeltQuoteState::Pending,
"Quote should be pending"
);
let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let melt_request2 = create_test_melt_request(&proofs2, &pending_quote);
let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap();
let saga2 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_result2 = saga2
.setup_melt(
&melt_request2,
verification2,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await;
assert!(
setup_result2.is_err(),
"Setup should fail with pending quote"
);
if let Err(error) = setup_result2 {
let error_msg = error.to_string().to_lowercase();
assert!(
error_msg.contains("pending")
|| error_msg.contains("quote")
|| error_msg.contains("state"),
"Error should mention pending quote, got: {}",
error
);
}
}
#[tokio::test]
async fn test_empty_inputs() {
let mint = create_test_mint().await.unwrap();
use cdk_common::nuts::{MeltRequest, Proofs};
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let empty_proofs = Proofs::new();
let melt_request = MeltRequest::new(quote.id.clone(), empty_proofs, None);
let verification_result = mint.verify_inputs(melt_request.inputs()).await;
assert!(
verification_result.is_err(),
"Verification should fail with empty proofs"
);
let error = verification_result.unwrap_err();
let error_msg = error.to_string().to_lowercase();
assert!(
error_msg.contains("empty") || error_msg.contains("no") || error_msg.contains("input"),
"Error should mention empty inputs, got: {}",
error
);
let sagas = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
assert!(sagas.is_empty(), "No saga should be persisted");
}
#[tokio::test]
async fn test_recovery_empty_input_ys() {
}
#[tokio::test]
async fn test_recovery_no_melt_request() {
let mint = create_test_mint().await.unwrap();
let amount = Amount::from(10_000);
let proofs = mint_test_proofs(&mint, amount).await.unwrap();
let quote = create_test_melt_quote(&mint, amount).await;
let melt_request = create_test_melt_request(&proofs, "e);
assert!(
melt_request.outputs().is_none(),
"Should have no change outputs"
);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let input_ys = proofs.ys().unwrap();
drop(setup_saga);
assert_saga_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed without change outputs");
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, None).await;
}
#[tokio::test]
async fn test_recovery_order_on_startup() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let input_ys = proofs.ys().unwrap();
drop(setup_saga);
assert_saga_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
let pending_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
assert_eq!(
pending_quote.state,
MeltQuoteState::Pending,
"Quote should be pending"
);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed");
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, None).await;
let recovered_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
assert_eq!(
recovered_quote.state,
MeltQuoteState::Unpaid,
"Quote should be reset to unpaid"
);
let new_proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let new_request = create_test_melt_request(&new_proofs, &recovered_quote);
let new_verification = mint.verify_inputs(new_request.inputs()).await.unwrap();
let new_saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let _new_setup = new_saga
.setup_melt(
&new_request,
new_verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
}
#[tokio::test]
async fn test_no_duplicate_recovery() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let input_ys = proofs.ys().unwrap();
drop(setup_saga);
assert_saga_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
mint.recover_from_incomplete_melt_sagas()
.await
.expect("First recovery should succeed");
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, None).await;
let recovered_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
assert_eq!(recovered_quote.state, MeltQuoteState::Unpaid);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Second recovery should succeed (idempotent)");
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, None).await;
let still_recovered_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.unwrap();
assert_eq!(still_recovered_quote.state, MeltQuoteState::Unpaid);
}
#[tokio::test]
async fn test_operation_id_uniqueness_and_tracking() {
let mint = create_test_mint().await.unwrap();
let mut operation_ids = Vec::new();
for _ in 0..10 {
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
operation_ids.push(operation_id);
drop(setup_saga);
}
let unique_ids: std::collections::HashSet<_> = operation_ids.iter().collect();
assert_eq!(
unique_ids.len(),
operation_ids.len(),
"All {} operation IDs should be unique",
operation_ids.len()
);
let sagas = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
for operation_id in &operation_ids {
assert!(
sagas.iter().any(|s| s.operation_id == *operation_id),
"Saga {} should be trackable in database",
operation_id
);
}
}
#[tokio::test]
async fn test_saga_drop_without_finalize() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
drop(setup_saga);
let saga_in_db = assert_saga_exists(&mint, &operation_id).await;
assert_eq!(saga_in_db.operation_id, operation_id);
}
#[tokio::test]
async fn test_saga_drop_after_payment() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
let (payment_saga, decision) = setup_saga
.attempt_internal_settlement(&melt_request)
.await
.unwrap();
let confirmed_saga = payment_saga.make_payment(decision).await.unwrap();
let saga_in_db = assert_saga_exists(&mint, &operation_id).await;
match &saga_in_db.state {
cdk_common::mint::SagaStateEnum::Melt(state) => {
assert_eq!(
*state,
MeltSagaState::PaymentAttempted,
"Saga state should be PaymentAttempted after make_payment"
);
}
_ => panic!("Expected Melt saga state"),
}
drop(confirmed_saga);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed");
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Spent)).await;
let final_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("Quote should exist");
assert_eq!(
final_quote.state,
MeltQuoteState::Paid,
"Quote should be marked as Paid after recovery finalization"
);
}
#[tokio::test]
async fn test_payment_attempted_state_triggers_ln_check() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let saga_before_payment = assert_saga_exists(&mint, &operation_id).await;
match &saga_before_payment.state {
cdk_common::mint::SagaStateEnum::Melt(state) => {
assert_eq!(
*state,
MeltSagaState::SetupComplete,
"Initial state should be SetupComplete"
);
}
_ => panic!("Expected Melt saga state"),
}
let (payment_saga, decision) = setup_saga
.attempt_internal_settlement(&melt_request)
.await
.unwrap();
let confirmed_saga = payment_saga.make_payment(decision).await.unwrap();
let saga_after_payment = assert_saga_exists(&mint, &operation_id).await;
match &saga_after_payment.state {
cdk_common::mint::SagaStateEnum::Melt(state) => {
assert_eq!(
*state,
MeltSagaState::PaymentAttempted,
"State should be PaymentAttempted after make_payment"
);
}
_ => panic!("Expected Melt saga state"),
}
drop(confirmed_saga);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed");
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, Some(State::Spent)).await;
let final_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("Quote should exist");
assert_eq!(
final_quote.state,
MeltQuoteState::Paid,
"Quote should be Paid - LN backend check should have triggered finalization"
);
}
#[tokio::test]
async fn test_setup_complete_state_compensates() {
let mint = create_test_mint().await.unwrap();
let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let input_ys = proofs.ys().unwrap();
let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let melt_request = create_test_melt_request(&proofs, "e);
let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
let saga = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga = saga
.setup_melt(
&melt_request,
verification,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let operation_id = *setup_saga.state_data.operation.id();
let saga_in_db = assert_saga_exists(&mint, &operation_id).await;
match &saga_in_db.state {
cdk_common::mint::SagaStateEnum::Melt(state) => {
assert_eq!(
*state,
MeltSagaState::SetupComplete,
"State should be SetupComplete"
);
}
_ => panic!("Expected Melt saga state"),
}
assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
drop(setup_saga);
mint.recover_from_incomplete_melt_sagas()
.await
.expect("Recovery should succeed");
assert_saga_not_exists(&mint, &operation_id).await;
assert_proofs_state(&mint, &input_ys, None).await;
let final_quote = mint
.localstore
.get_melt_quote("e.id)
.await
.unwrap()
.expect("Quote should exist");
assert_eq!(
final_quote.state,
MeltQuoteState::Unpaid,
"Quote should be Unpaid - compensation should have reset it"
);
}
async fn create_test_melt_quote(
mint: &crate::mint::Mint,
amount: Amount,
) -> cdk_common::mint::MeltQuote {
use cdk_common::melt::MeltQuoteRequest;
use cdk_common::nuts::MeltQuoteBolt11Request;
use cdk_common::CurrencyUnit;
use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
let fake_description = FakeInvoiceDescription {
pay_invoice_state: MeltQuoteState::Paid, check_payment_state: MeltQuoteState::Paid, pay_err: false, check_err: false, };
let amount_msats: u64 = amount.into();
let invoice = create_fake_invoice(
amount_msats,
serde_json::to_string(&fake_description).unwrap(),
);
let bolt11_request = MeltQuoteBolt11Request {
request: invoice,
unit: CurrencyUnit::Sat,
options: None,
};
let request = MeltQuoteRequest::Bolt11(bolt11_request);
let quote_response = mint.get_melt_quote(request).await.unwrap();
let quote = mint
.localstore
.get_melt_quote("e_response.quote)
.await
.unwrap()
.expect("Quote should exist in database");
quote
}
fn create_test_melt_request(
proofs: &cdk_common::nuts::Proofs,
quote: &cdk_common::mint::MeltQuote,
) -> cdk_common::nuts::MeltRequest<cdk_common::QuoteId> {
use cdk_common::nuts::MeltRequest;
MeltRequest::new(
quote.id.clone(),
proofs.clone(),
None, )
}
async fn assert_saga_exists(mint: &crate::mint::Mint, operation_id: &uuid::Uuid) -> Saga {
let sagas = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
sagas
.into_iter()
.find(|s| s.operation_id == *operation_id)
.expect("Saga should exist in database")
}
async fn assert_saga_not_exists(mint: &crate::mint::Mint, operation_id: &uuid::Uuid) {
let sagas = mint
.localstore
.get_incomplete_sagas(OperationKind::Melt)
.await
.unwrap();
assert!(
!sagas.iter().any(|s| s.operation_id == *operation_id),
"Saga should not exist in database"
);
}
async fn assert_proofs_state(
mint: &crate::mint::Mint,
ys: &[cdk_common::PublicKey],
expected_state: Option<State>,
) {
let states = mint.localstore.get_proofs_states(ys).await.unwrap();
for state in states {
assert_eq!(state, expected_state, "Proof state mismatch");
}
}
#[tokio::test]
async fn test_duplicate_lookup_id_prevents_second_pending() {
use cdk_common::melt::MeltQuoteRequest;
use cdk_common::nuts::MeltQuoteBolt11Request;
use cdk_common::CurrencyUnit;
use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
let mint = create_test_mint().await.unwrap();
let fake_description = FakeInvoiceDescription {
pay_invoice_state: MeltQuoteState::Paid,
check_payment_state: MeltQuoteState::Paid,
pay_err: false,
check_err: false,
};
let amount_msats: u64 = 9000;
let invoice = create_fake_invoice(
amount_msats,
serde_json::to_string(&fake_description).unwrap(),
);
let bolt11_request1 = MeltQuoteBolt11Request {
request: invoice.clone(),
unit: CurrencyUnit::Sat,
options: None,
};
let quote_response1 = mint
.get_melt_quote(MeltQuoteRequest::Bolt11(bolt11_request1))
.await
.unwrap();
let bolt11_request2 = MeltQuoteBolt11Request {
request: invoice,
unit: CurrencyUnit::Sat,
options: None,
};
let quote_response2 = mint
.get_melt_quote(MeltQuoteRequest::Bolt11(bolt11_request2))
.await
.unwrap();
let quote1 = mint
.localstore
.get_melt_quote("e_response1.quote)
.await
.unwrap()
.expect("Quote 1 should exist");
let quote2 = mint
.localstore
.get_melt_quote("e_response2.quote)
.await
.unwrap()
.expect("Quote 2 should exist");
assert_eq!(
quote1.request_lookup_id, quote2.request_lookup_id,
"Both quotes should have the same request_lookup_id"
);
let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let melt_request1 = create_test_melt_request(&proofs1, "e1);
let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
let saga1 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga1 = saga1
.setup_melt(
&melt_request1,
verification1,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let (payment_saga1, decision1) = setup_saga1
.attempt_internal_settlement(&melt_request1)
.await
.unwrap();
let confirmed_saga1 = payment_saga1.make_payment(decision1).await.unwrap();
drop(confirmed_saga1);
let pending_quote1 = mint
.localstore
.get_melt_quote("e1.id)
.await
.unwrap()
.unwrap();
assert_eq!(
pending_quote1.state,
MeltQuoteState::Pending,
"Quote 1 should be pending"
);
let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let melt_request2 = create_test_melt_request(&proofs2, "e2);
let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap();
let saga2 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_result2 = saga2
.setup_melt(
&melt_request2,
verification2,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await;
assert!(
setup_result2.is_err(),
"Setup should fail when another quote with same lookup_id is already pending"
);
if let Err(error) = setup_result2 {
let error_msg = error.to_string().to_lowercase();
assert!(
error_msg.contains("duplicate")
|| error_msg.contains("pending")
|| error_msg.contains("already paid"),
"Error should mention duplicate, pending, or already paid, got: {}",
error
);
}
let still_unpaid_quote2 = mint
.localstore
.get_melt_quote("e2.id)
.await
.unwrap()
.unwrap();
assert_eq!(
still_unpaid_quote2.state,
MeltQuoteState::Unpaid,
"Quote 2 should still be unpaid"
);
}
#[tokio::test]
async fn test_paid_lookup_id_prevents_pending() {
use cdk_common::melt::MeltQuoteRequest;
use cdk_common::nuts::MeltQuoteBolt11Request;
use cdk_common::CurrencyUnit;
use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
let mint = create_test_mint().await.unwrap();
let fake_description = FakeInvoiceDescription {
pay_invoice_state: MeltQuoteState::Paid,
check_payment_state: MeltQuoteState::Paid,
pay_err: false,
check_err: false,
};
let amount_msats: u64 = 9000;
let invoice = create_fake_invoice(
amount_msats,
serde_json::to_string(&fake_description).unwrap(),
);
let bolt11_request1 = MeltQuoteBolt11Request {
request: invoice.clone(),
unit: CurrencyUnit::Sat,
options: None,
};
let quote_response1 = mint
.get_melt_quote(MeltQuoteRequest::Bolt11(bolt11_request1))
.await
.unwrap();
let bolt11_request2 = MeltQuoteBolt11Request {
request: invoice,
unit: CurrencyUnit::Sat,
options: None,
};
let quote_response2 = mint
.get_melt_quote(MeltQuoteRequest::Bolt11(bolt11_request2))
.await
.unwrap();
let quote1 = mint
.localstore
.get_melt_quote("e_response1.quote)
.await
.unwrap()
.expect("Quote 1 should exist");
let quote2 = mint
.localstore
.get_melt_quote("e_response2.quote)
.await
.unwrap()
.expect("Quote 2 should exist");
let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let melt_request1 = create_test_melt_request(&proofs1, "e1);
let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
let saga1 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_saga1 = saga1
.setup_melt(
&melt_request1,
verification1,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let (payment_saga, decision) = setup_saga1
.attempt_internal_settlement(&melt_request1)
.await
.unwrap();
let confirmed_saga = payment_saga.make_payment(decision).await.unwrap();
let _response = confirmed_saga.finalize().await.unwrap();
let paid_quote1 = mint
.localstore
.get_melt_quote("e1.id)
.await
.unwrap()
.unwrap();
assert_eq!(
paid_quote1.state,
MeltQuoteState::Paid,
"Quote 1 should be paid"
);
let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let melt_request2 = create_test_melt_request(&proofs2, "e2);
let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap();
let saga2 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let setup_result2 = saga2
.setup_melt(
&melt_request2,
verification2,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await;
assert!(
setup_result2.is_err(),
"Setup should fail when another quote with same lookup_id is already paid"
);
if let Err(error) = setup_result2 {
let error_msg = error.to_string().to_lowercase();
assert!(
error_msg.contains("duplicate")
|| error_msg.contains("paid")
|| error_msg.contains("pending"),
"Error should mention duplicate or paid, got: {}",
error
);
}
}
#[tokio::test]
async fn test_different_lookup_ids_allow_concurrent_pending() {
let mint = create_test_mint().await.unwrap();
let quote1 = create_test_melt_quote(&mint, Amount::from(9_000)).await;
let quote2 = create_test_melt_quote(&mint, Amount::from(8_000)).await;
assert_ne!(
quote1.request_lookup_id, quote2.request_lookup_id,
"Quotes should have different request_lookup_ids"
);
let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let melt_request1 = create_test_melt_request(&proofs1, "e1);
let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
let saga1 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let _setup_saga1 = saga1
.setup_melt(
&melt_request1,
verification1,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
let melt_request2 = create_test_melt_request(&proofs2, "e2);
let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap();
let saga2 = MeltSaga::new(
std::sync::Arc::new(mint.clone()),
mint.localstore(),
mint.pubsub_manager(),
);
let _setup_saga2 = saga2
.setup_melt(
&melt_request2,
verification2,
PaymentMethod::Known(KnownMethod::Bolt11),
)
.await
.unwrap();
let pending_quote1 = mint
.localstore
.get_melt_quote("e1.id)
.await
.unwrap()
.unwrap();
let pending_quote2 = mint
.localstore
.get_melt_quote("e2.id)
.await
.unwrap()
.unwrap();
assert_eq!(
pending_quote1.state,
MeltQuoteState::Pending,
"Quote 1 should be pending"
);
assert_eq!(
pending_quote2.state,
MeltQuoteState::Pending,
"Quote 2 should be pending"
);
}