|
|
@@ -2496,3 +2496,371 @@ async fn assert_proofs_state(
|
|
|
assert_eq!(state, expected_state, "Proof state mismatch");
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// Duplicate request_lookup_id Constraint Tests
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+/// Test: Cannot set melt quote to pending if another quote with same lookup_id is already pending
|
|
|
+///
|
|
|
+/// This test verifies that when two melt quotes share the same request_lookup_id,
|
|
|
+/// only one can be in PENDING state at a time.
|
|
|
+#[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};
|
|
|
+
|
|
|
+ // STEP 1: Setup test environment
|
|
|
+ let mint = create_test_mint().await.unwrap();
|
|
|
+
|
|
|
+ // Create a fake invoice description
|
|
|
+ let fake_description = FakeInvoiceDescription {
|
|
|
+ pay_invoice_state: MeltQuoteState::Paid,
|
|
|
+ check_payment_state: MeltQuoteState::Paid,
|
|
|
+ pay_err: false,
|
|
|
+ check_err: false,
|
|
|
+ };
|
|
|
+
|
|
|
+ // Create a single invoice that will be used for both quotes
|
|
|
+ let amount_msats: u64 = 9000;
|
|
|
+ let invoice = create_fake_invoice(
|
|
|
+ amount_msats,
|
|
|
+ serde_json::to_string(&fake_description).unwrap(),
|
|
|
+ );
|
|
|
+
|
|
|
+ // STEP 2: Create two melt quotes for the same invoice (same request_lookup_id)
|
|
|
+ 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();
|
|
|
+
|
|
|
+ // Retrieve full quotes
|
|
|
+ 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");
|
|
|
+
|
|
|
+ // Verify both quotes have the same lookup_id
|
|
|
+ assert_eq!(
|
|
|
+ quote1.request_lookup_id, quote2.request_lookup_id,
|
|
|
+ "Both quotes should have the same request_lookup_id"
|
|
|
+ );
|
|
|
+
|
|
|
+ // STEP 3: Setup first melt saga (puts quote1 in PENDING state)
|
|
|
+ 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)
|
|
|
+ .await
|
|
|
+ .unwrap();
|
|
|
+
|
|
|
+ // Continue through the payment flow to release any transaction locks
|
|
|
+ // The quote will stay in PENDING state because FakeWallet returns Paid
|
|
|
+ // but we don't call finalize()
|
|
|
+ let (payment_saga1, decision1) = setup_saga1
|
|
|
+ .attempt_internal_settlement(&melt_request1)
|
|
|
+ .await
|
|
|
+ .unwrap();
|
|
|
+
|
|
|
+ // Make payment but don't finalize - keeps quote in PENDING
|
|
|
+ let confirmed_saga1 = payment_saga1.make_payment(decision1).await.unwrap();
|
|
|
+
|
|
|
+ // Drop the saga to release resources (simulates crash before finalize)
|
|
|
+ drop(confirmed_saga1);
|
|
|
+
|
|
|
+ // Verify quote1 is now pending
|
|
|
+ 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"
|
|
|
+ );
|
|
|
+
|
|
|
+ // STEP 4: Try to setup second saga with quote2 (same lookup_id)
|
|
|
+ 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).await;
|
|
|
+
|
|
|
+ // STEP 5: Verify second setup fails due to duplicate pending lookup_id
|
|
|
+ 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 should mention duplicate or pending, got: {}",
|
|
|
+ error
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify quote2 is still unpaid
|
|
|
+ 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"
|
|
|
+ );
|
|
|
+
|
|
|
+ // SUCCESS: Duplicate pending lookup_id prevented!
|
|
|
+}
|
|
|
+
|
|
|
+/// Test: Cannot set melt quote to pending if another quote with same lookup_id is already paid
|
|
|
+///
|
|
|
+/// This test verifies that once a melt quote with a specific request_lookup_id is paid,
|
|
|
+/// no other quote with the same lookup_id can transition to pending.
|
|
|
+#[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};
|
|
|
+
|
|
|
+ // STEP 1: Setup test environment
|
|
|
+ let mint = create_test_mint().await.unwrap();
|
|
|
+
|
|
|
+ // Create a fake invoice description
|
|
|
+ let fake_description = FakeInvoiceDescription {
|
|
|
+ pay_invoice_state: MeltQuoteState::Paid,
|
|
|
+ check_payment_state: MeltQuoteState::Paid,
|
|
|
+ pay_err: false,
|
|
|
+ check_err: false,
|
|
|
+ };
|
|
|
+
|
|
|
+ // Create a single invoice that will be used for both quotes
|
|
|
+ let amount_msats: u64 = 9000;
|
|
|
+ let invoice = create_fake_invoice(
|
|
|
+ amount_msats,
|
|
|
+ serde_json::to_string(&fake_description).unwrap(),
|
|
|
+ );
|
|
|
+
|
|
|
+ // STEP 2: Create two melt quotes for the same invoice (same request_lookup_id)
|
|
|
+ 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();
|
|
|
+
|
|
|
+ // Retrieve full quotes
|
|
|
+ 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");
|
|
|
+
|
|
|
+ // STEP 3: Complete the first melt (marks quote1 as PAID)
|
|
|
+ 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)
|
|
|
+ .await
|
|
|
+ .unwrap();
|
|
|
+
|
|
|
+ // Complete the full melt flow for quote1
|
|
|
+ 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();
|
|
|
+
|
|
|
+ // Verify quote1 is now paid
|
|
|
+ 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"
|
|
|
+ );
|
|
|
+
|
|
|
+ // STEP 4: Try to setup second saga with quote2 (same lookup_id as paid quote)
|
|
|
+ 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).await;
|
|
|
+
|
|
|
+ // STEP 5: Verify second setup fails due to already paid lookup_id
|
|
|
+ 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
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // SUCCESS: Paid lookup_id prevents new pending!
|
|
|
+}
|
|
|
+
|
|
|
+/// Test: Different lookup_ids allow concurrent pending quotes
|
|
|
+///
|
|
|
+/// This test verifies that melt quotes with different request_lookup_ids
|
|
|
+/// can both be in PENDING state simultaneously.
|
|
|
+#[tokio::test]
|
|
|
+async fn test_different_lookup_ids_allow_concurrent_pending() {
|
|
|
+ // STEP 1: Setup test environment
|
|
|
+ let mint = create_test_mint().await.unwrap();
|
|
|
+
|
|
|
+ // STEP 2: Create two quotes with different lookup_ids (different invoices)
|
|
|
+ let quote1 = create_test_melt_quote(&mint, Amount::from(9_000)).await;
|
|
|
+ let quote2 = create_test_melt_quote(&mint, Amount::from(8_000)).await;
|
|
|
+
|
|
|
+ // Verify quotes have different lookup_ids
|
|
|
+ assert_ne!(
|
|
|
+ quote1.request_lookup_id, quote2.request_lookup_id,
|
|
|
+ "Quotes should have different request_lookup_ids"
|
|
|
+ );
|
|
|
+
|
|
|
+ // STEP 3: Setup first saga (puts quote1 in PENDING state)
|
|
|
+ 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)
|
|
|
+ .await
|
|
|
+ .unwrap();
|
|
|
+
|
|
|
+ // STEP 4: Setup second saga (puts quote2 in PENDING state)
|
|
|
+ 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)
|
|
|
+ .await
|
|
|
+ .unwrap();
|
|
|
+
|
|
|
+ // STEP 5: Verify both quotes are pending
|
|
|
+ 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"
|
|
|
+ );
|
|
|
+
|
|
|
+ // SUCCESS: Different lookup_ids allow concurrent pending!
|
|
|
+}
|