| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707 |
- //! 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_fake_wallet::create_fake_invoice;
- 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;
- // Check initial amounts after minting
- let total_issued = mint.total_issued().await.unwrap();
- let total_redeemed = mint.total_redeemed().await.unwrap();
- let initial_issued = total_issued
- .get(&keyset_id)
- .copied()
- .unwrap_or(Amount::ZERO);
- let initial_redeemed = total_redeemed
- .get(&keyset_id)
- .copied()
- .unwrap_or(Amount::ZERO);
- assert_eq!(
- initial_issued,
- Amount::from(100),
- "Should have issued 100 sats"
- );
- assert_eq!(
- initial_redeemed,
- Amount::ZERO,
- "Should have redeemed 0 sats initially"
- );
- let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).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::<Vec<_>>())
- .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::<Vec<_>>(),
- )
- .await
- .expect("Failed to get blind signatures");
- assert_eq!(
- saved_signatures.len(),
- swap_response.signatures.len(),
- "All signatures should be saved"
- );
- // Check keyset amounts after swap
- // Swap redeems old proofs (100 sats) and issues new proofs (100 sats)
- let total_issued = mint.total_issued().await.unwrap();
- let total_redeemed = mint.total_redeemed().await.unwrap();
- let after_issued = total_issued
- .get(&keyset_id)
- .copied()
- .unwrap_or(Amount::ZERO);
- let after_redeemed = total_redeemed
- .get(&keyset_id)
- .copied()
- .unwrap_or(Amount::ZERO);
- assert_eq!(
- after_issued,
- Amount::from(200),
- "Should have issued 200 sats total (initial 100 + swap 100)"
- );
- assert_eq!(
- after_redeemed,
- Amount::from(100),
- "Should have redeemed 100 sats from the swap"
- );
- }
- /// 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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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 that swap requests with empty inputs or outputs are rejected:
- /// Case 1: Empty outputs (inputs without outputs)
- /// Case 2: Empty inputs (outputs without inputs)
- /// Both should fail. Currently returns UnitMismatch (11010) instead of
- /// TransactionUnbalanced (11002) because there are no keyset IDs to determine units.
- #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
- async fn test_swap_empty_inputs_or_outputs() {
- 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");
- // Case 1: Swap request with inputs but empty outputs
- // This represents trying to destroy tokens (inputs with no outputs)
- let swap_request_empty_outputs = SwapRequest::new(proofs.clone(), vec![]);
- match mint.process_swap_request(swap_request_empty_outputs).await {
- Err(cdk::Error::TransactionUnbalanced(_, _, _)) => {
- // This would be the more appropriate error
- }
- Err(err) => panic!("Wrong error type for empty outputs: {:?}", err),
- Ok(_) => panic!("Swap with empty outputs should not succeed"),
- }
- // Case 2: Swap request with empty inputs but with outputs
- // This represents trying to create tokens from nothing
- let keyset_id = get_keyset_id(&mint).await;
- let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
- let preswap = PreMintSecrets::random(
- keyset_id,
- 100.into(),
- &SplitTarget::default(),
- &fee_and_amounts,
- )
- .expect("Failed to create preswap");
- let swap_request_empty_inputs = SwapRequest::new(vec![], preswap.blinded_messages());
- match mint.process_swap_request(swap_request_empty_inputs).await {
- Err(cdk::Error::TransactionUnbalanced(_, _, _)) => {
- // This would be the more appropriate error
- }
- Err(err) => panic!("Wrong error type for empty inputs: {:?}", err),
- Ok(_) => panic!("Swap with empty inputs 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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())).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::<Vec<_>>())
- .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,
- cdk_integration_tests::standard_keyset_amounts(32),
- 100,
- )
- .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::<Vec<_>>())).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 melt with fees enabled and swap-before-melt optimization:
- /// 1. Create mint with keyset that has fees (1000 ppk = 1 sat per proof)
- /// 2. Fund wallet with proofs using default split (optimal denominations)
- /// 3. Call melt() - should automatically swap if proofs don't match exactly
- /// 4. Verify fee calculations are reasonable
- ///
- /// Fee calculation:
- /// - Initial: 4096 sats in optimal denominations
- /// - Melt: 1000 sats, fee_reserve = 20 sats (2%)
- /// - inputs_needed = 1020 sats
- /// - Target split for 1020: [512, 256, 128, 64, 32, 16, 8, 4] = 8 proofs
- /// - target_fee = 8 sats
- /// - inputs_total_needed = 1028 sats
- ///
- /// The wallet uses two-step selection:
- /// - Step 1: Try to find exact proofs for inputs_needed (no swap fee)
- /// - Step 2: If not exact, select proofs for inputs_total_needed and swap
- #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
- async fn test_melt_with_fees_swap_before_melt() {
- 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 1000 ppk = 1 sat per proof fee
- mint.rotate_keyset(
- CurrencyUnit::Sat,
- cdk_integration_tests::standard_keyset_amounts(32),
- 1000, // 1 sat per proof
- )
- .await
- .expect("Failed to rotate keyset");
- tokio::time::sleep(std::time::Duration::from_millis(100)).await;
- // Fund with default split target to get optimal denominations
- // Use larger amount to ensure enough margin for swap fees
- let initial_amount = 4096u64;
- fund_wallet(wallet.clone(), initial_amount, None)
- .await
- .expect("Failed to fund wallet");
- let initial_balance: u64 = wallet.total_balance().await.unwrap().into();
- assert_eq!(initial_balance, initial_amount);
- let proofs = wallet.get_unspent_proofs().await.unwrap();
- let proof_amounts: Vec<u64> = proofs.iter().map(|p| u64::from(p.amount)).collect();
- tracing::info!("Proofs after funding: {:?}", proof_amounts);
- let proofs_total: u64 = proof_amounts.iter().sum();
- assert_eq!(
- proofs_total, initial_amount,
- "Total proofs should equal funded amount"
- );
- // Create melt quote for 1000 sats (1_000_000 msats)
- // Fake wallet: fee_reserve = max(1, amount * 2%) = 20 sats
- let invoice = create_fake_invoice(1_000_000, "".to_string()); // 1000 sats in msats
- let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
- let quote_amount: u64 = melt_quote.amount.into();
- let fee_reserve: u64 = melt_quote.fee_reserve.into();
- tracing::info!(
- "Melt quote: amount={}, fee_reserve={}",
- quote_amount,
- fee_reserve
- );
- let initial_proof_count = proofs.len();
- tracing::info!(
- "Initial state: {} proofs, {} sats",
- initial_proof_count,
- proofs_total
- );
- // Perform melt
- let melted = wallet.melt(&melt_quote.id).await.unwrap();
- let melt_amount: u64 = melted.amount.into();
- let ln_fee_paid: u64 = melted.fee_paid.into();
- tracing::info!(
- "Melt completed: amount={}, ln_fee_paid={}",
- melt_amount,
- ln_fee_paid
- );
- assert_eq!(melt_amount, quote_amount, "Melt amount should match quote");
- // Get final balance and calculate fees
- let final_balance: u64 = wallet.total_balance().await.unwrap().into();
- let total_spent = initial_amount - final_balance;
- let total_fees = total_spent - melt_amount;
- tracing::info!(
- "Balance: initial={}, final={}, total_spent={}, melt_amount={}, total_fees={}",
- initial_amount,
- final_balance,
- total_spent,
- melt_amount,
- total_fees
- );
- // Calculate input fees (swap + melt)
- let input_fees = total_fees - ln_fee_paid;
- tracing::info!(
- "Fee breakdown: total_fees={}, ln_fee={}, input_fees (swap+melt)={}",
- total_fees,
- ln_fee_paid,
- input_fees
- );
- // Verify input fees are reasonable
- // With swap-before-melt optimization, we use fewer proofs for the melt
- // Melt uses ~8 proofs for optimal split of 1028, so input_fee ~= 8
- // Swap (if any) also has fees, but the optimization minimizes total fees
- assert!(
- input_fees > 0,
- "Should have some input fees with fee-enabled keyset"
- );
- assert!(
- input_fees <= 20,
- "Input fees {} should be reasonable (not too high)",
- input_fees
- );
- // Verify we have change remaining
- assert!(final_balance > 0, "Should have change remaining after melt");
- tracing::info!(
- "Test passed: spent {} sats, fees {} (ln={}, input={}), remaining {}",
- total_spent,
- total_fees,
- ln_fee_paid,
- input_fees,
- final_balance
- );
- }
- /// Tests the "exact match" early return path in melt_with_metadata.
- /// When proofs already exactly match inputs_needed_amount, no swap is required.
- ///
- /// This tests Step 1 of the two-step selection:
- /// - Select proofs for inputs_needed_amount
- /// - If exact match, use proofs directly without swap
- #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
- async fn test_melt_exact_match_no_swap() {
- 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");
- // Use keyset WITHOUT fees to make exact match easier
- // (default keyset has no fees)
- // Fund with exactly inputs_needed_amount to trigger the exact match path
- // For a 1000 sat melt, fee_reserve = max(1, 1000 * 2%) = 20 sats
- // inputs_needed = 1000 + 20 = 1020 sats
- let initial_amount = 1020u64;
- fund_wallet(wallet.clone(), initial_amount, None)
- .await
- .expect("Failed to fund wallet");
- let initial_balance: u64 = wallet.total_balance().await.unwrap().into();
- assert_eq!(initial_balance, initial_amount);
- let proofs_before = wallet.get_unspent_proofs().await.unwrap();
- tracing::info!(
- "Proofs before melt: {:?}",
- proofs_before
- .iter()
- .map(|p| u64::from(p.amount))
- .collect::<Vec<_>>()
- );
- // Create melt quote for 1000 sats
- // fee_reserve = max(1, 1000 * 2%) = 20 sats
- // inputs_needed = 1000 + 20 = 1020 sats = our exact balance
- let invoice = create_fake_invoice(1_000_000, "".to_string());
- let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
- let quote_amount: u64 = melt_quote.amount.into();
- let fee_reserve: u64 = melt_quote.fee_reserve.into();
- let inputs_needed = quote_amount + fee_reserve;
- tracing::info!(
- "Melt quote: amount={}, fee_reserve={}, inputs_needed={}",
- quote_amount,
- fee_reserve,
- inputs_needed
- );
- // Perform melt
- let melted = wallet.melt(&melt_quote.id).await.unwrap();
- let melt_amount: u64 = melted.amount.into();
- let ln_fee_paid: u64 = melted.fee_paid.into();
- tracing::info!(
- "Melt completed: amount={}, ln_fee_paid={}",
- melt_amount,
- ln_fee_paid
- );
- assert_eq!(melt_amount, quote_amount, "Melt amount should match quote");
- // Get final balance
- let final_balance: u64 = wallet.total_balance().await.unwrap().into();
- let total_spent = initial_amount - final_balance;
- let total_fees = total_spent - melt_amount;
- tracing::info!(
- "Balance: initial={}, final={}, total_spent={}, total_fees={}",
- initial_amount,
- final_balance,
- total_spent,
- total_fees
- );
- // With no keyset fees and no swap needed, total fees should just be ln_fee
- // (no input fees since default keyset has 0 ppk)
- assert_eq!(
- total_fees, ln_fee_paid,
- "Total fees should equal LN fee (no swap or input fees with 0 ppk keyset)"
- );
- tracing::info!("Test passed: exact match path used, no swap needed");
- }
- /// Tests melt with small amounts where swap margin is too tight.
- /// When fees are high relative to the melt amount, the swap-before-melt
- /// optimization may not have enough margin to cover both input and output fees.
- /// In this case, the wallet should fall back to using proofs directly.
- ///
- /// Scenario:
- /// - Fund with 8 sats
- /// - Melt 5 sats (with 2% fee_reserve = 1 sat min, so inputs_needed = 6)
- /// - With 1 sat per proof fee, the swap margin becomes too tight
- /// - Should still succeed by falling back to direct melt
- #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
- async fn test_melt_small_amount_tight_margin() {
- 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 1000 ppk = 1 sat per proof fee
- mint.rotate_keyset(
- CurrencyUnit::Sat,
- cdk_integration_tests::standard_keyset_amounts(32),
- 1000,
- )
- .await
- .expect("Failed to rotate keyset");
- tokio::time::sleep(std::time::Duration::from_millis(100)).await;
- // Fund with enough to cover melt + fees, but amounts that will trigger swap
- // 32 sats gives us enough margin even with 1 sat/proof fees
- let initial_amount = 32;
- fund_wallet(wallet.clone(), initial_amount, None)
- .await
- .expect("Failed to fund wallet");
- let initial_balance: u64 = wallet.total_balance().await.unwrap().into();
- assert_eq!(initial_balance, initial_amount);
- let proofs = wallet.get_unspent_proofs().await.unwrap();
- tracing::info!(
- "Proofs after funding: {:?}",
- proofs
- .iter()
- .map(|p| u64::from(p.amount))
- .collect::<Vec<_>>()
- );
- // Create melt quote for 5 sats
- // fee_reserve = max(1, 5 * 2%) = 1 sat
- // inputs_needed = 5 + 1 = 6 sats
- let invoice = create_fake_invoice(5_000, "".to_string()); // 5 sats in msats
- let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
- let quote_amount: u64 = melt_quote.amount.into();
- let fee_reserve: u64 = melt_quote.fee_reserve.into();
- tracing::info!(
- "Melt quote: amount={}, fee_reserve={}, inputs_needed={}",
- quote_amount,
- fee_reserve,
- quote_amount + fee_reserve
- );
- // This should succeed even with tight margins
- let melted = wallet
- .melt(&melt_quote.id)
- .await
- .expect("Melt should succeed even with tight swap margin");
- let melt_amount: u64 = melted.amount.into();
- assert_eq!(melt_amount, quote_amount, "Melt amount should match quote");
- let final_balance: u64 = wallet.total_balance().await.unwrap().into();
- tracing::info!(
- "Melt completed: amount={}, fee_paid={}, final_balance={}",
- melted.amount,
- melted.fee_paid,
- final_balance
- );
- // Verify balance decreased appropriately
- assert!(
- final_balance < initial_balance,
- "Balance should decrease after melt"
- );
- }
- /// Tests melt where swap proofs barely cover swap_amount + input_fee.
- ///
- /// This is a regression test for a bug where the swap-before-melt was called
- /// with include_fees=true, causing it to try to add output fees on top of
- /// swap_amount + input_fee. When proofs_to_swap had just barely enough value,
- /// this caused InsufficientFunds error.
- ///
- /// Scenario (from the bug):
- /// - Balance: proofs like [4, 2, 1, 1] = 8 sats
- /// - Melt: 5 sats + 1 fee_reserve = 6 inputs_needed
- /// - target_fee = 1 (for optimal output split)
- /// - inputs_total_needed = 7
- /// - proofs_to_send = [4, 2] = 6, proofs_to_swap = [1, 1] = 2
- /// - swap_amount = 1 sat (7 - 6)
- /// - swap input_fee = 1 sat (2 proofs)
- /// - Before fix: include_fees=true tried to add output fee, causing failure
- /// - After fix: include_fees=false, swap succeeds
- #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
- async fn test_melt_swap_tight_margin_regression() {
- 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 250 ppk = 0.25 sat per proof fee (same as original bug scenario)
- // This means 4 proofs = 1 sat fee
- mint.rotate_keyset(
- CurrencyUnit::Sat,
- cdk_integration_tests::standard_keyset_amounts(32),
- 250,
- )
- .await
- .expect("Failed to rotate keyset");
- tokio::time::sleep(std::time::Duration::from_millis(100)).await;
- // Fund with 100 sats using default split to get optimal denominations
- // This should give us proofs like [64, 32, 4] or similar power-of-2 split
- let initial_amount = 100;
- fund_wallet(wallet.clone(), initial_amount, None)
- .await
- .expect("Failed to fund wallet");
- let initial_balance: u64 = wallet.total_balance().await.unwrap().into();
- assert_eq!(initial_balance, initial_amount);
- let proofs = wallet.get_unspent_proofs().await.unwrap();
- let proof_amounts: Vec<u64> = proofs.iter().map(|p| u64::from(p.amount)).collect();
- tracing::info!("Proofs after funding: {:?}", proof_amounts);
- // Create melt quote for 5 sats (5000 msats)
- // fee_reserve = max(1, 5 * 2%) = 1 sat
- // inputs_needed = 5 + 1 = 6 sats
- // The optimal split for 6 sats is [4, 2] (2 proofs)
- // target_fee = 1 sat (2 proofs * 0.25, rounded up)
- // inputs_total_needed = 7 sats
- //
- // If we don't have exact [4, 2] proofs, we'll need to swap.
- // The swap path is what triggered the original bug when proofs_to_swap
- // had tight margins and include_fees=true was incorrectly used.
- let invoice = create_fake_invoice(5_000, "".to_string());
- let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
- let quote_amount: u64 = melt_quote.amount.into();
- let fee_reserve: u64 = melt_quote.fee_reserve.into();
- tracing::info!(
- "Melt quote: amount={}, fee_reserve={}, inputs_needed={}",
- quote_amount,
- fee_reserve,
- quote_amount + fee_reserve
- );
- // This is the key test: melt should succeed even when swap is needed
- // Before the fix, include_fees=true in swap caused InsufficientFunds
- // After the fix, include_fees=false allows the swap to succeed
- let melted = wallet
- .melt(&melt_quote.id)
- .await
- .expect("Melt should succeed with swap-before-melt (regression test)");
- let melt_amount: u64 = melted.amount.into();
- assert_eq!(melt_amount, quote_amount, "Melt amount should match quote");
- let final_balance: u64 = wallet.total_balance().await.unwrap().into();
- tracing::info!(
- "Melt completed: amount={}, fee_paid={}, final_balance={}",
- melted.amount,
- melted.fee_paid,
- final_balance
- );
- // Should have change remaining
- assert!(
- final_balance < initial_balance,
- "Balance should decrease after melt"
- );
- assert!(final_balance > 0, "Should have change remaining");
- }
- /// 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::<Vec<_>>())).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::<Vec<_>>())).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<String> = 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<String, Vec<State>> = 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_default()
- .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::<Vec<_>>())).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)
- }
- }
- }
- }
- /// Tests that wallet correctly increments keyset counters when receiving proofs
- /// from multiple keysets and then performing operations with them.
- ///
- /// This test validates:
- /// 1. Wallet can receive proofs from multiple different keysets
- /// 2. Counter is correctly incremented for the target keyset during swap
- /// 3. Database maintains separate counters for each keyset
- #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
- async fn test_wallet_multi_keyset_counter_updates() {
- 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 initial 100 sats using first keyset
- fund_wallet(wallet.clone(), 100, None)
- .await
- .expect("Failed to fund wallet");
- let first_keyset_id = get_keyset_id(&mint).await;
- // Rotate to a second keyset
- mint.rotate_keyset(
- CurrencyUnit::Sat,
- cdk_integration_tests::standard_keyset_amounts(32),
- 0,
- )
- .await
- .expect("Failed to rotate keyset");
- // Wait for keyset rotation to propagate
- tokio::time::sleep(std::time::Duration::from_millis(100)).await;
- // Refresh wallet keysets to know about the new keyset
- wallet
- .refresh_keysets()
- .await
- .expect("Failed to refresh wallet keysets");
- // Fund wallet again with 100 sats using second keyset
- fund_wallet(wallet.clone(), 100, None)
- .await
- .expect("Failed to fund wallet with second keyset");
- let second_keyset_id = mint
- .pubkeys()
- .keysets
- .iter()
- .find(|k| k.id != first_keyset_id)
- .expect("Should have second keyset")
- .id;
- // Verify we now have proofs from two different keysets
- let all_proofs = wallet
- .get_unspent_proofs()
- .await
- .expect("Could not get proofs");
- let keysets_in_use: std::collections::HashSet<_> =
- all_proofs.iter().map(|p| p.keyset_id).collect();
- assert_eq!(
- keysets_in_use.len(),
- 2,
- "Should have proofs from 2 different keysets"
- );
- assert!(
- keysets_in_use.contains(&first_keyset_id),
- "Should have proofs from first keyset"
- );
- assert!(
- keysets_in_use.contains(&second_keyset_id),
- "Should have proofs from second keyset"
- );
- // Get initial total issued and redeemed for both keysets before swap
- let total_issued_before = mint.total_issued().await.unwrap();
- let total_redeemed_before = mint.total_redeemed().await.unwrap();
- let first_keyset_issued_before = total_issued_before
- .get(&first_keyset_id)
- .copied()
- .unwrap_or(Amount::ZERO);
- let first_keyset_redeemed_before = total_redeemed_before
- .get(&first_keyset_id)
- .copied()
- .unwrap_or(Amount::ZERO);
- let second_keyset_issued_before = total_issued_before
- .get(&second_keyset_id)
- .copied()
- .unwrap_or(Amount::ZERO);
- let second_keyset_redeemed_before = total_redeemed_before
- .get(&second_keyset_id)
- .copied()
- .unwrap_or(Amount::ZERO);
- tracing::info!(
- "Before swap - First keyset: issued={}, redeemed={}",
- first_keyset_issued_before,
- first_keyset_redeemed_before
- );
- tracing::info!(
- "Before swap - Second keyset: issued={}, redeemed={}",
- second_keyset_issued_before,
- second_keyset_redeemed_before
- );
- // Both keysets should have issued 100 sats
- assert_eq!(
- first_keyset_issued_before,
- Amount::from(100),
- "First keyset should have issued 100 sats"
- );
- assert_eq!(
- second_keyset_issued_before,
- Amount::from(100),
- "Second keyset should have issued 100 sats"
- );
- // Neither should have redeemed anything yet
- assert_eq!(
- first_keyset_redeemed_before,
- Amount::ZERO,
- "First keyset should have redeemed 0 sats before swap"
- );
- assert_eq!(
- second_keyset_redeemed_before,
- Amount::ZERO,
- "Second keyset should have redeemed 0 sats before swap"
- );
- // Now perform a swap with all proofs - this should only increment the counter
- // for the active (second) keyset, not for the first keyset
- let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
- let total_amount = all_proofs.total_amount().expect("Should get total amount");
- // Create swap using the active (second) keyset
- let preswap = PreMintSecrets::random(
- second_keyset_id,
- total_amount,
- &SplitTarget::default(),
- &fee_and_amounts,
- )
- .expect("Failed to create preswap");
- let swap_request = SwapRequest::new(all_proofs.clone(), preswap.blinded_messages());
- // Execute the swap
- let swap_response = mint
- .process_swap_request(swap_request)
- .await
- .expect("Swap should succeed");
- // Verify response
- assert_eq!(
- swap_response.signatures.len(),
- preswap.blinded_messages().len(),
- "Should receive signature for each blinded message"
- );
- // All the new proofs should be from the second (active) keyset
- let keys = mint
- .pubkeys()
- .keysets
- .iter()
- .find(|k| k.id == second_keyset_id)
- .expect("Should find second keyset")
- .keys
- .clone();
- let new_proofs = construct_proofs(
- swap_response.signatures,
- preswap.rs(),
- preswap.secrets(),
- &keys,
- )
- .expect("Failed to construct proofs");
- // Verify all new proofs use the second keyset
- for proof in &new_proofs {
- assert_eq!(
- proof.keyset_id, second_keyset_id,
- "All new proofs should use the active (second) keyset"
- );
- }
- // Verify total issued and redeemed after swap
- let total_issued_after = mint.total_issued().await.unwrap();
- let total_redeemed_after = mint.total_redeemed().await.unwrap();
- let first_keyset_issued_after = total_issued_after
- .get(&first_keyset_id)
- .copied()
- .unwrap_or(Amount::ZERO);
- let first_keyset_redeemed_after = total_redeemed_after
- .get(&first_keyset_id)
- .copied()
- .unwrap_or(Amount::ZERO);
- let second_keyset_issued_after = total_issued_after
- .get(&second_keyset_id)
- .copied()
- .unwrap_or(Amount::ZERO);
- let second_keyset_redeemed_after = total_redeemed_after
- .get(&second_keyset_id)
- .copied()
- .unwrap_or(Amount::ZERO);
- tracing::info!(
- "After swap - First keyset: issued={}, redeemed={}",
- first_keyset_issued_after,
- first_keyset_redeemed_after
- );
- tracing::info!(
- "After swap - Second keyset: issued={}, redeemed={}",
- second_keyset_issued_after,
- second_keyset_redeemed_after
- );
- // After swap:
- // - First keyset: issued stays 100, redeemed increases by 100 (all its proofs were spent in swap)
- // - Second keyset: issued increases by 200 (original 100 + new 100 from swap output),
- // redeemed increases by 100 (its proofs from first funding were spent)
- assert_eq!(
- first_keyset_issued_after,
- Amount::from(100),
- "First keyset issued should stay 100 sats (no new issuance)"
- );
- assert_eq!(
- first_keyset_redeemed_after,
- Amount::from(100),
- "First keyset should have redeemed 100 sats (all its proofs spent in swap)"
- );
- assert_eq!(
- second_keyset_issued_after,
- Amount::from(300),
- "Second keyset should have issued 300 sats total (100 initial + 100 the second funding + 100 from swap output from the old keyset)"
- );
- assert_eq!(
- second_keyset_redeemed_after,
- Amount::from(100),
- "Second keyset should have redeemed 100 sats (its proofs from initial funding spent in swap)"
- );
- // The test verifies that:
- // 1. We can have proofs from multiple keysets in a wallet
- // 2. Swap operation processes inputs from any keyset but creates outputs using active keyset
- // 3. The keyset_counter table correctly handles counters for different keysets independently
- // 4. The database upsert logic in increment_keyset_counter works for multiple keysets
- // 5. Total issued and redeemed are tracked correctly per keyset during multi-keyset swaps
- }
|