From 69650c2ef9b75cd0867d729372a2f17bcb430fb1 Mon Sep 17 00:00:00 2001 From: tsk Date: Sun, 12 Oct 2025 23:16:52 +0100 Subject: [PATCH] feat: swap tests (#1187) --- .../tests/test_swap_flow.rs | 903 ++++++++++++++++++ justfile | 3 + 2 files changed, 906 insertions(+) create mode 100644 crates/cdk-integration-tests/tests/test_swap_flow.rs diff --git a/crates/cdk-integration-tests/tests/test_swap_flow.rs b/crates/cdk-integration-tests/tests/test_swap_flow.rs new file mode 100644 index 00000000..2d851e57 --- /dev/null +++ b/crates/cdk-integration-tests/tests/test_swap_flow.rs @@ -0,0 +1,903 @@ +//! Comprehensive tests for the current swap flow +//! +//! These tests validate the swap operation's behavior including: +//! - Happy path: successful token swaps +//! - Error handling: validation failures, rollback scenarios +//! - Edge cases: concurrent operations, double-spending +//! - State management: proof states, blinded message tracking +//! +//! The tests focus on the current implementation using ProofWriter and BlindedMessageWriter +//! patterns to ensure proper cleanup and rollback behavior. + +use std::collections::HashMap; +use std::sync::Arc; + +use cashu::amount::SplitTarget; +use cashu::dhke::construct_proofs; +use cashu::{CurrencyUnit, Id, PreMintSecrets, SecretKey, SpendingConditions, State, SwapRequest}; +use cdk::mint::Mint; +use cdk::nuts::nut00::ProofsMethods; +use cdk::Amount; +use cdk_integration_tests::init_pure_tests::*; + +/// Helper to get the active keyset ID from a mint +async fn get_keyset_id(mint: &Mint) -> Id { + let keys = mint.pubkeys().keysets.first().unwrap().clone(); + keys.verify_id() + .expect("Keyset ID generation is successful"); + keys.id +} + +/// Tests the complete happy path of a swap operation: +/// 1. Wallet is funded with tokens +/// 2. Blinded messages are added to database +/// 3. Outputs are signed by mint +/// 4. Input proofs are verified +/// 5. Transaction is balanced +/// 6. Proofs are added and marked as spent +/// 7. Blind signatures are saved +/// All steps should succeed and database should be in consistent state. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_happy_path() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create test wallet"); + + // Fund wallet with 100 sats + fund_wallet(wallet.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keyset_id = get_keyset_id(&mint).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + // Create swap request for same amount (100 sats) + let preswap = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); + + // Execute swap + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Swap should succeed"); + + // Verify response contains correct number of signatures + assert_eq!( + swap_response.signatures.len(), + preswap.blinded_messages().len(), + "Should receive signature for each blinded message" + ); + + // Verify input proofs are marked as spent + let states = mint + .localstore() + .get_proofs_states(&proofs.iter().map(|p| p.y().unwrap()).collect::>()) + .await + .expect("Failed to get proof states"); + + for state in states { + assert_eq!( + State::Spent, + state.expect("State should be known"), + "All input proofs should be marked as spent" + ); + } + + // Verify blind signatures were saved + let saved_signatures = mint + .localstore() + .get_blind_signatures( + &preswap + .blinded_messages() + .iter() + .map(|bm| bm.blinded_secret) + .collect::>(), + ) + .await + .expect("Failed to get blind signatures"); + + assert_eq!( + saved_signatures.len(), + swap_response.signatures.len(), + "All signatures should be saved" + ); +} + +/// Tests that duplicate blinded messages are rejected: +/// 1. First swap with blinded messages succeeds +/// 2. Second swap attempt with same blinded messages fails +/// 3. BlindedMessageWriter should prevent reuse +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_duplicate_blinded_messages() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create test wallet"); + + // Fund wallet with 200 sats (enough for two swaps) + fund_wallet(wallet.clone(), 200, None) + .await + .expect("Failed to fund wallet"); + + let all_proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + // Split proofs into two sets + let mid = all_proofs.len() / 2; + let proofs1: Vec<_> = all_proofs.iter().take(mid).cloned().collect(); + let proofs2: Vec<_> = all_proofs.iter().skip(mid).cloned().collect(); + + let keyset_id = get_keyset_id(&mint).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + // Create blinded messages for first swap + let preswap = PreMintSecrets::random( + keyset_id, + proofs1.total_amount().unwrap(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let blinded_messages = preswap.blinded_messages(); + + // First swap should succeed + let swap_request1 = SwapRequest::new(proofs1, blinded_messages.clone()); + mint.process_swap_request(swap_request1) + .await + .expect("First swap should succeed"); + + // Second swap with SAME blinded messages should fail + let swap_request2 = SwapRequest::new(proofs2, blinded_messages.clone()); + let result = mint.process_swap_request(swap_request2).await; + + assert!( + result.is_err(), + "Second swap with duplicate blinded messages should fail" + ); +} + +/// Tests that swap correctly rejects double-spending attempts: +/// 1. First swap with proofs succeeds +/// 2. Second swap with same proofs fails with TokenAlreadySpent +/// 3. ProofWriter should detect already-spent proofs +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_double_spend_detection() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create test wallet"); + + // Fund wallet with 100 sats + fund_wallet(wallet.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keyset_id = get_keyset_id(&mint).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + // First swap + let preswap1 = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let swap_request1 = SwapRequest::new(proofs.clone(), preswap1.blinded_messages()); + mint.process_swap_request(swap_request1) + .await + .expect("First swap should succeed"); + + // Second swap with same proofs should fail + let preswap2 = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let swap_request2 = SwapRequest::new(proofs.clone(), preswap2.blinded_messages()); + let result = mint.process_swap_request(swap_request2).await; + + match result { + Err(cdk::Error::TokenAlreadySpent) => { + // Expected error + } + Err(err) => panic!("Wrong error type: {:?}", err), + Ok(_) => panic!("Double spend should not succeed"), + } +} + +/// Tests that unbalanced swap requests are rejected: +/// Case 1: Output amount < Input amount (trying to steal from mint) +/// Case 2: Output amount > Input amount (trying to create tokens) +/// Both should fail with TransactionUnbalanced error. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_unbalanced_transaction_detection() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create test wallet"); + + // Fund wallet with 100 sats + fund_wallet(wallet.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keyset_id = get_keyset_id(&mint).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + // Case 1: Try to swap for LESS (95 < 100) - underpaying + let preswap_less = PreMintSecrets::random( + keyset_id, + 95.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let swap_request_less = SwapRequest::new(proofs.clone(), preswap_less.blinded_messages()); + + match mint.process_swap_request(swap_request_less).await { + Err(cdk::Error::TransactionUnbalanced(_, _, _)) => { + // Expected error + } + Err(err) => panic!("Wrong error type for underpay: {:?}", err), + Ok(_) => panic!("Unbalanced swap (underpay) should not succeed"), + } + + // Case 2: Try to swap for MORE (105 > 100) - overpaying/creating tokens + let preswap_more = PreMintSecrets::random( + keyset_id, + 105.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let swap_request_more = SwapRequest::new(proofs.clone(), preswap_more.blinded_messages()); + + match mint.process_swap_request(swap_request_more).await { + Err(cdk::Error::TransactionUnbalanced(_, _, _)) => { + // Expected error + } + Err(err) => panic!("Wrong error type for overpay: {:?}", err), + Ok(_) => panic!("Unbalanced swap (overpay) should not succeed"), + } +} + +/// Tests P2PK (Pay-to-Public-Key) spending conditions: +/// 1. Create proofs locked to a public key +/// 2. Attempt swap without signature - should fail +/// 3. Attempt swap with valid signature - should succeed +/// Validates NUT-11 signature enforcement. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_p2pk_signature_validation() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create test wallet"); + + // Fund wallet with 100 sats + fund_wallet(wallet.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let input_proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keyset_id = get_keyset_id(&mint).await; + let secret_key = SecretKey::generate(); + + // Create P2PK locked outputs + let spending_conditions = SpendingConditions::new_p2pk(secret_key.public_key(), None); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + let pre_swap = PreMintSecrets::with_conditions( + keyset_id, + 100.into(), + &SplitTarget::default(), + &spending_conditions, + &fee_and_amounts, + ) + .expect("Failed to create P2PK preswap"); + + let swap_request = SwapRequest::new(input_proofs.clone(), pre_swap.blinded_messages()); + + // First swap to get P2PK locked proofs + let keys = mint.pubkeys().keysets.first().cloned().unwrap().keys; + + let post_swap = mint + .process_swap_request(swap_request) + .await + .expect("Initial swap should succeed"); + + // Construct proofs from swap response + let mut p2pk_proofs = construct_proofs( + post_swap.signatures, + pre_swap.rs(), + pre_swap.secrets(), + &keys, + ) + .expect("Failed to construct proofs"); + + // Try to spend P2PK proofs WITHOUT signature - should fail + let preswap_unsigned = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let swap_request_unsigned = + SwapRequest::new(p2pk_proofs.clone(), preswap_unsigned.blinded_messages()); + + match mint.process_swap_request(swap_request_unsigned).await { + Err(cdk::Error::NUT11(cdk::nuts::nut11::Error::SignaturesNotProvided)) => { + // Expected error + } + Err(err) => panic!("Wrong error type: {:?}", err), + Ok(_) => panic!("Unsigned P2PK spend should fail"), + } + + // Sign the proofs with correct key + for proof in &mut p2pk_proofs { + proof + .sign_p2pk(secret_key.clone()) + .expect("Failed to sign proof"); + } + + // Try again WITH signature - should succeed + let preswap_signed = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let swap_request_signed = SwapRequest::new(p2pk_proofs, preswap_signed.blinded_messages()); + + mint.process_swap_request(swap_request_signed) + .await + .expect("Signed P2PK spend should succeed"); +} + +/// Tests rollback behavior when duplicate blinded messages are used: +/// This validates that the BlindedMessageWriter prevents reuse of blinded messages. +/// 1. First swap with blinded messages succeeds +/// 2. Second swap with same blinded messages fails +/// 3. The failure should happen early (during blinded message addition) +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_rollback_on_duplicate_blinded_message() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create test wallet"); + + // Fund with enough for multiple swaps + fund_wallet(wallet.clone(), 200, None) + .await + .expect("Failed to fund wallet"); + + let all_proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let mid = all_proofs.len() / 2; + let proofs1: Vec<_> = all_proofs.iter().take(mid).cloned().collect(); + let proofs2: Vec<_> = all_proofs.iter().skip(mid).cloned().collect(); + + let keyset_id = get_keyset_id(&mint).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + // Create shared blinded messages + let preswap = PreMintSecrets::random( + keyset_id, + proofs1.total_amount().unwrap(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let blinded_messages = preswap.blinded_messages(); + + // Extract proof2 ys before moving proofs2 + let proof2_ys: Vec<_> = proofs2.iter().map(|p| p.y().unwrap()).collect(); + + // First swap succeeds + let swap1 = SwapRequest::new(proofs1, blinded_messages.clone()); + mint.process_swap_request(swap1) + .await + .expect("First swap should succeed"); + + // Second swap with duplicate blinded messages should fail early + // The BlindedMessageWriter should detect duplicate and prevent the swap + let swap2 = SwapRequest::new(proofs2, blinded_messages.clone()); + let result = mint.process_swap_request(swap2).await; + + assert!( + result.is_err(), + "Duplicate blinded messages should cause failure" + ); + + // Verify the second set of proofs are NOT marked as spent + // (since the swap failed before processing them) + let states = mint + .localstore() + .get_proofs_states(&proof2_ys) + .await + .expect("Failed to get proof states"); + + for state in states { + assert!( + state.is_none(), + "Proofs from failed swap should not be marked as spent" + ); + } +} + +/// Tests concurrent swap attempts with same proofs: +/// Spawns 3 concurrent tasks trying to swap the same proofs. +/// Only one should succeed, others should fail with TokenAlreadySpent or TokenPending. +/// Validates that concurrent access is properly handled. +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn test_swap_concurrent_double_spend_prevention() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create test wallet"); + + // Fund wallet + fund_wallet(wallet.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keyset_id = get_keyset_id(&mint).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + // Create 3 different swap requests with SAME proofs but different outputs + let preswap1 = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap 1"); + + let preswap2 = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap 2"); + + let preswap3 = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap 3"); + + let swap_request1 = SwapRequest::new(proofs.clone(), preswap1.blinded_messages()); + let swap_request2 = SwapRequest::new(proofs.clone(), preswap2.blinded_messages()); + let swap_request3 = SwapRequest::new(proofs.clone(), preswap3.blinded_messages()); + + // Spawn concurrent tasks + let mint1 = mint.clone(); + let mint2 = mint.clone(); + let mint3 = mint.clone(); + + let task1 = tokio::spawn(async move { mint1.process_swap_request(swap_request1).await }); + let task2 = tokio::spawn(async move { mint2.process_swap_request(swap_request2).await }); + let task3 = tokio::spawn(async move { mint3.process_swap_request(swap_request3).await }); + + // Wait for all tasks + let results = tokio::try_join!(task1, task2, task3).expect("Tasks should complete"); + + // Count successes and failures + let mut success_count = 0; + let mut failure_count = 0; + + for result in [results.0, results.1, results.2] { + match result { + Ok(_) => success_count += 1, + Err(cdk::Error::TokenAlreadySpent) | Err(cdk::Error::TokenPending) => { + failure_count += 1 + } + Err(err) => panic!("Unexpected error: {:?}", err), + } + } + + assert_eq!( + success_count, 1, + "Exactly one swap should succeed in concurrent scenario" + ); + assert_eq!( + failure_count, 2, + "Exactly two swaps should fail in concurrent scenario" + ); + + // Verify all proofs are marked as spent + let states = mint + .localstore() + .get_proofs_states(&proofs.iter().map(|p| p.y().unwrap()).collect::>()) + .await + .expect("Failed to get proof states"); + + for state in states { + assert_eq!( + State::Spent, + state.expect("State should be known"), + "All proofs should be marked as spent after concurrent attempts" + ); + } +} + +/// Tests swap with fees enabled: +/// 1. Create mint with keyset that has fees (1 sat per proof) +/// 2. Fund wallet with many small proofs +/// 3. Attempt swap without paying fee - should fail +/// 4. Attempt swap with correct fee deduction - should succeed +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_with_fees() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + + let wallet = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create test wallet"); + + // Rotate to keyset with 1 sat per proof fee + mint.rotate_keyset(CurrencyUnit::Sat, 32, 1) + .await + .expect("Failed to rotate keyset"); + + // Fund with 1000 sats as individual 1-sat proofs using the fee-based keyset + // Wait a bit for keyset to be available + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + fund_wallet(wallet.clone(), 1000, Some(SplitTarget::Value(Amount::ONE))) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + // Take 100 proofs (100 sats total, will need to pay fee) + let hundred_proofs: Vec<_> = proofs.iter().take(100).cloned().collect(); + + // Get the keyset ID from the proofs (which will be the fee-based keyset) + let keyset_id = hundred_proofs[0].keyset_id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + // Try to swap for 100 outputs (same as input) - should fail due to unpaid fee + let preswap_no_fee = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let swap_no_fee = SwapRequest::new(hundred_proofs.clone(), preswap_no_fee.blinded_messages()); + + match mint.process_swap_request(swap_no_fee).await { + Err(cdk::Error::TransactionUnbalanced(_, _, _)) => { + // Expected - didn't pay the fee + } + Err(err) => panic!("Wrong error type: {:?}", err), + Ok(_) => panic!("Should fail when fee not paid"), + } + + // Calculate correct fee (1 sat per input proof in this keyset) + let fee = hundred_proofs.len() as u64; // 1 sat per proof = 100 sats fee + let output_amount = 100 - fee; + + // Swap with correct fee deduction - should succeed if output_amount > 0 + if output_amount > 0 { + let preswap_with_fee = PreMintSecrets::random( + keyset_id, + output_amount.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap with fee"); + + let swap_with_fee = + SwapRequest::new(hundred_proofs.clone(), preswap_with_fee.blinded_messages()); + + mint.process_swap_request(swap_with_fee) + .await + .expect("Swap with correct fee should succeed"); + } +} + +/// Tests that swap correctly handles amount overflow: +/// Attempts to create outputs that would overflow u64 when summed. +/// This should be rejected before any database operations occur. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_amount_overflow_protection() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create test wallet"); + + // Fund wallet + fund_wallet(wallet.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keyset_id = get_keyset_id(&mint).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + // Try to create outputs that would overflow + // 2^63 + 2^63 + small amount would overflow u64 + let large_amount = 2_u64.pow(63); + + let pre_mint1 = PreMintSecrets::random( + keyset_id, + large_amount.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create pre_mint1"); + + let pre_mint2 = PreMintSecrets::random( + keyset_id, + large_amount.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create pre_mint2"); + + let mut combined_pre_mint = PreMintSecrets::random( + keyset_id, + 1.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create combined_pre_mint"); + + combined_pre_mint.combine(pre_mint1); + combined_pre_mint.combine(pre_mint2); + + let swap_request = SwapRequest::new(proofs, combined_pre_mint.blinded_messages()); + + // Should fail with overflow/amount error + match mint.process_swap_request(swap_request).await { + Err(cdk::Error::NUT03(cdk::nuts::nut03::Error::Amount(_))) + | Err(cdk::Error::AmountOverflow) + | Err(cdk::Error::AmountError(_)) + | Err(cdk::Error::TransactionUnbalanced(_, _, _)) => { + // Any of these errors are acceptable for overflow + } + Err(err) => panic!("Unexpected error type: {:?}", err), + Ok(_) => panic!("Overflow swap should not succeed"), + } +} + +/// Tests swap state transitions through pubsub notifications: +/// 1. Subscribe to proof state changes +/// 2. Execute swap +/// 3. Verify Pending then Spent state transitions are received +/// Validates NUT-17 notification behavior. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_state_transition_notifications() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create test wallet"); + + // Fund wallet + fund_wallet(wallet.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keyset_id = get_keyset_id(&mint).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + let preswap = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); + + // Subscribe to proof state changes + let proof_ys: Vec = proofs.iter().map(|p| p.y().unwrap().to_string()).collect(); + + let mut listener = mint + .pubsub_manager() + .subscribe(cdk::subscription::Params { + kind: cdk::nuts::nut17::Kind::ProofState, + filters: proof_ys.clone(), + id: Arc::new("test_swap_notifications".into()), + }) + .expect("Should subscribe successfully"); + + // Execute swap + mint.process_swap_request(swap_request) + .await + .expect("Swap should succeed"); + + // Give pubsub time to deliver messages + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Collect all state transition notifications + let mut state_transitions: HashMap> = HashMap::new(); + + while let Some(msg) = listener.try_recv() { + match msg.into_inner() { + cashu::NotificationPayload::ProofState(cashu::ProofState { y, state, .. }) => { + state_transitions + .entry(y.to_string()) + .or_insert_with(Vec::new) + .push(state); + } + _ => panic!("Unexpected notification type"), + } + } + + // Verify each proof went through Pending -> Spent transition + for y in proof_ys { + let transitions = state_transitions + .get(&y) + .expect("Should have transitions for proof"); + + assert_eq!( + transitions, + &vec![State::Pending, State::Spent], + "Proof should transition from Pending to Spent" + ); + } +} + +/// Tests that swap fails gracefully when proof states cannot be updated: +/// This would test the rollback path where proofs are added but state update fails. +/// In the current implementation, this should trigger rollback of both proofs and blinded messages. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_proof_state_consistency() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create test wallet"); + + // Fund wallet + fund_wallet(wallet.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keyset_id = get_keyset_id(&mint).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + // Execute successful swap + let preswap = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); + + mint.process_swap_request(swap_request) + .await + .expect("Swap should succeed"); + + // Verify all proofs have consistent state (Spent) + let proof_ys: Vec<_> = proofs.iter().map(|p| p.y().unwrap()).collect(); + + let states = mint + .localstore() + .get_proofs_states(&proof_ys) + .await + .expect("Failed to get proof states"); + + // All states should be Some(Spent) - none should be None or Pending + for (i, state) in states.iter().enumerate() { + match state { + Some(State::Spent) => { + // Expected state + } + Some(other_state) => { + panic!("Proof {} in unexpected state: {:?}", i, other_state) + } + None => { + panic!("Proof {} has no state (should be Spent)", i) + } + } + } +} diff --git a/justfile b/justfile index c250fa58..22d93fd1 100644 --- a/justfile +++ b/justfile @@ -75,6 +75,9 @@ test-pure db="memory": # Run pure integration tests (cargo test will only build what's needed for the test) CDK_TEST_DB_TYPE={{db}} cargo test -p cdk-integration-tests --test integration_tests_pure -- --test-threads 1 + + # Run swap flow tests (detailed testing of swap operation) + CDK_TEST_DB_TYPE={{db}} cargo test -p cdk-integration-tests --test test_swap_flow -- --test-threads 1 test-all db="memory": #!/usr/bin/env bash