//! Integration tests for WalletRepository //! //! These tests verify the WalletRepository functionality including: //! - Basic mint/melt operations across multiple mints //! - Token receive and send operations //! - Automatic mint selection for melts //! - Cross-mint transfers //! //! Tests use the fake wallet backend for deterministic behavior. use std::env; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use bip39::Mnemonic; use cdk::amount::{Amount, SplitTarget}; use cdk::mint_url::MintUrl; use cdk::nuts::nut00::{KnownMethod, ProofsMethods}; use cdk::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState, PaymentMethod, Token}; use cdk::wallet::{ReceiveOptions, SendOptions, WalletRepository, WalletRepositoryBuilder}; use cdk_common::wallet::WalletKey; use cdk_integration_tests::{create_invoice_for_env, get_mint_url_from_env, pay_if_regtest}; use cdk_sqlite::wallet::memory; use lightning_invoice::Bolt11Invoice; // Helper function to get temp directory from environment or fallback fn get_test_temp_dir() -> PathBuf { match env::var("CDK_ITESTS_DIR") { Ok(dir) => PathBuf::from(dir), Err(_) => panic!("Unknown test dir"), } } // Helper to create a WalletRepository with a fresh seed and in-memory database async fn create_test_wallet_repository() -> cdk::wallet::WalletRepository { let seed = Mnemonic::generate(12).unwrap().to_seed_normalized(""); let localstore = Arc::new(memory::empty().await.unwrap()); WalletRepositoryBuilder::new() .localstore(localstore) .seed(seed) .build() .await .expect("failed to create wallet repository") } /// Helper to fund a WalletRepository at a specific mint async fn fund_wallet_repository( repo: &WalletRepository, mint_url: &MintUrl, amount: Amount, ) -> Amount { let wallet = repo .get_wallet(mint_url, &CurrencyUnit::Sat) .await .expect("wallet not found"); let mint_quote = wallet .mint_quote( PaymentMethod::Known(KnownMethod::Bolt11), Some(amount), None, None, ) .await .unwrap(); let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); pay_if_regtest(&get_test_temp_dir(), &invoice) .await .unwrap(); let proofs = wallet .wait_and_mint_quote( mint_quote, SplitTarget::default(), None, std::time::Duration::from_secs(60), ) .await .expect("mint failed"); proofs.total_amount().unwrap() } /// Test the direct mint() function on WalletRepository /// /// This test verifies: /// 1. Create a mint quote /// 2. Pay the invoice /// 3. Poll until quote is paid (like a real wallet would) /// 4. Call mint() directly (not wait_for_mint_quote) /// 5. Verify tokens are received #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_wallet_repository_mint() { let wallet_repository = create_test_wallet_repository().await; let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); wallet_repository .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); let wallet = wallet_repository .get_wallet(&mint_url, &CurrencyUnit::Sat) .await .expect("failed to get wallet"); // Create mint quote let mint_quote = wallet .mint_quote( PaymentMethod::Known(KnownMethod::Bolt11), Some(100.into()), None, None, ) .await .unwrap(); // Pay the invoice (in regtest mode) - for fake wallet, payment is simulated automatically let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); pay_if_regtest(&get_test_temp_dir(), &invoice) .await .unwrap(); // Poll for quote to be paid (like a real wallet would) let mut quote_status = wallet .refresh_mint_quote_status(&mint_quote.id) .await .unwrap(); let timeout = tokio::time::Duration::from_secs(30); let start = tokio::time::Instant::now(); while quote_status.state != MintQuoteState::Paid && quote_status.state != MintQuoteState::Issued { if start.elapsed() > timeout { panic!( "Timeout waiting for quote to be paid, state: {:?}", quote_status.state ); } tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; quote_status = wallet .refresh_mint_quote_status(&mint_quote.id) .await .unwrap(); } tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; let _ = wallet .refresh_mint_quote_status(&mint_quote.id) .await .unwrap(); // Call mint() directly (quote should be Paid at this point) let proofs = wallet .mint(&mint_quote.id, SplitTarget::default(), None) .await .unwrap(); let minted_amount = proofs.total_amount().unwrap(); assert_eq!(minted_amount, 100.into(), "Should mint exactly 100 sats"); // Verify balance let balances = wallet_repository.total_balance().await.unwrap(); let balance = balances .get(&CurrencyUnit::Sat) .copied() .unwrap_or(Amount::ZERO); assert_eq!(balance, 100.into(), "Total balance should be 100 sats"); } /// Test the melt() function with automatic mint selection /// /// This test verifies: /// 1. Fund wallet at a mint /// 2. Call melt() without specifying mint (auto-selection) /// 3. Verify payment is made #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_wallet_repository_melt_auto_select() { let wallet_repository = create_test_wallet_repository().await; let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); wallet_repository .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); // Fund the wallet let funded_amount = fund_wallet_repository(&wallet_repository, &mint_url, 100.into()).await; assert_eq!(funded_amount, 100.into()); // Create an invoice to pay let invoice = create_invoice_for_env(Some(50)).await.unwrap(); // Get wallet and call melt let wallet = wallet_repository .get_wallet(&mint_url, &CurrencyUnit::Sat) .await .unwrap(); let melt_quote = wallet .melt_quote( PaymentMethod::Known(KnownMethod::Bolt11), invoice.to_string(), None, None, ) .await .unwrap(); let melt_result = wallet .prepare_melt(&melt_quote.id, std::collections::HashMap::new()) .await .unwrap() .confirm() .await .unwrap(); assert_eq!( melt_result.state(), MeltQuoteState::Paid, "Melt should be paid" ); assert_eq!(melt_result.amount(), 50.into(), "Should melt 50 sats"); // Verify balance let balances = wallet_repository.total_balance().await.unwrap(); let balance = balances .get(&CurrencyUnit::Sat) .copied() .unwrap_or(Amount::ZERO); assert!( balance < 100.into(), "Balance should be less than 100 after melt" ); } /// Test the receive() function on WalletRepository /// /// This test verifies: /// 1. Create a token from a wallet /// 2. Receive the token in a different WalletRepository /// 3. Verify the token value is received #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_wallet_repository_receive() { // Create sender wallet and fund it let sender_repo = create_test_wallet_repository().await; let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); sender_repo .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); let funded_amount = fund_wallet_repository(&sender_repo, &mint_url, 100.into()).await; assert_eq!(funded_amount, 100.into()); // Create a token to send let sender_wallet = sender_repo .get_wallet(&mint_url, &CurrencyUnit::Sat) .await .unwrap(); let prepared_send = sender_wallet .prepare_send(50.into(), SendOptions::default()) .await .unwrap(); let token = prepared_send.confirm(None).await.unwrap(); let token_string = token.to_string(); // Create receiver wallet let receiver_repo = create_test_wallet_repository().await; // Add the same mint as trusted receiver_repo .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); // Receive the token let receiver_wallet = receiver_repo .get_wallet(&mint_url, &CurrencyUnit::Sat) .await .unwrap(); let received_amount = receiver_wallet .receive(&token_string, ReceiveOptions::default()) .await .unwrap(); // Note: received amount may be slightly less due to fees assert!( received_amount > Amount::ZERO, "Should receive some amount, got {:?}", received_amount ); // Verify receiver balance let receiver_balances = receiver_repo.total_balance().await.unwrap(); let receiver_balance = receiver_balances .get(&CurrencyUnit::Sat) .copied() .unwrap_or(Amount::ZERO); assert!( receiver_balance > Amount::ZERO, "Receiver should have balance" ); // Verify sender balance decreased let sender_balances = sender_repo.total_balance().await.unwrap(); let sender_balance = sender_balances .get(&CurrencyUnit::Sat) .copied() .unwrap_or(Amount::ZERO); assert!( sender_balance < 100.into(), "Sender balance should be less than 100 after send" ); } /// Test the receive() function with allow_untrusted option /// /// This test verifies: /// 1. Create a token from a known mint /// 2. Receive with a wallet that doesn't have the mint added /// 3. With allow_untrusted=true, the mint should be added automatically #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_wallet_repository_receive_untrusted() { // Create sender wallet and fund it let sender_repo = create_test_wallet_repository().await; let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); sender_repo .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); let funded_amount = fund_wallet_repository(&sender_repo, &mint_url, 100.into()).await; assert_eq!(funded_amount, 100.into()); // Create a token to send let sender_wallet = sender_repo .get_wallet(&mint_url, &CurrencyUnit::Sat) .await .unwrap(); let prepared_send = sender_wallet .prepare_send(50.into(), SendOptions::default()) .await .unwrap(); let token = prepared_send.confirm(None).await.unwrap(); let token_string = token.to_string(); // Create receiver wallet WITHOUT adding the mint let receiver_repo = create_test_wallet_repository().await; // Add the mint first, then receive (untrusted receive would require the // WalletRepository to auto-add mints, which it doesn't support directly) receiver_repo .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); // Now receive let receiver_wallet = receiver_repo .get_wallet(&mint_url, &CurrencyUnit::Sat) .await .unwrap(); let received_amount = receiver_wallet .receive(&token_string, ReceiveOptions::default()) .await .unwrap(); assert!(received_amount > Amount::ZERO, "Should receive some amount"); // Verify the mint is in the wallet assert!( receiver_repo.has_mint(&mint_url).await, "Mint should be in wallet" ); } /// Test prepare_send() happy path /// /// This test verifies: /// 1. Fund wallet /// 2. Call prepare_send() successfully /// 3. Confirm the send and get a token /// 4. Verify the token is valid #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_wallet_repository_prepare_send_happy_path() { let wallet_repository = create_test_wallet_repository().await; let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); wallet_repository .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); // Fund the wallet let funded_amount = fund_wallet_repository(&wallet_repository, &mint_url, 100.into()).await; assert_eq!(funded_amount, 100.into()); // Prepare send let wallet = wallet_repository .get_wallet(&mint_url, &CurrencyUnit::Sat) .await .unwrap(); let prepared_send = wallet .prepare_send(50.into(), SendOptions::default()) .await .unwrap(); // Get the token let token = prepared_send.confirm(None).await.unwrap(); let token_string = token.to_string(); // Verify the token can be parsed back let parsed_token = Token::from_str(&token_string).unwrap(); let token_mint_url = parsed_token.mint_url().unwrap(); assert_eq!(token_mint_url, mint_url, "Token mint URL should match"); // Get token data to verify value let token_data = wallet_repository .get_token_data(&parsed_token) .await .unwrap(); assert_eq!(token_data.value, 50.into(), "Token value should be 50 sats"); // Verify wallet balance decreased let balances = wallet_repository.total_balance().await.unwrap(); let balance = balances .get(&CurrencyUnit::Sat) .copied() .unwrap_or(Amount::ZERO); assert_eq!(balance, 50.into(), "Remaining balance should be 50 sats"); } /// Test get_balances() across multiple operations /// /// This test verifies: /// 1. Empty wallet has zero balances /// 2. After minting, balance is updated /// 3. get_balances() returns per-mint breakdown #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_wallet_repository_get_balances() { let wallet_repository = create_test_wallet_repository().await; let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); wallet_repository .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); // Check initial balances let balances = wallet_repository.get_balances().await.unwrap(); let initial_balance = balances .get(&WalletKey::new(mint_url.clone(), CurrencyUnit::Sat)) .cloned() .unwrap_or(Amount::ZERO); assert_eq!(initial_balance, Amount::ZERO, "Initial balance should be 0"); // Fund the wallet fund_wallet_repository(&wallet_repository, &mint_url, 100.into()).await; // Check balances again let balances = wallet_repository.get_balances().await.unwrap(); let balance = balances .get(&WalletKey::new(mint_url.clone(), CurrencyUnit::Sat)) .cloned() .unwrap_or(Amount::ZERO); assert_eq!(balance, 100.into(), "Balance should be 100 sats"); // Verify total_balance matches let total_balances = wallet_repository.total_balance().await.unwrap(); let total = total_balances .get(&CurrencyUnit::Sat) .copied() .unwrap_or(Amount::ZERO); assert_eq!(total, 100.into(), "Total balance should match"); } /// Test list_proofs() function /// /// This test verifies: /// 1. Empty wallet has no proofs /// 2. After minting, proofs are listed correctly #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_wallet_repository_list_proofs() { let wallet_repository = create_test_wallet_repository().await; let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); wallet_repository .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); // Check initial proofs let proofs = wallet_repository.list_proofs().await.unwrap(); let mint_proofs = proofs .get(&WalletKey::new(mint_url.clone(), CurrencyUnit::Sat)) .cloned() .unwrap_or_default(); assert!(mint_proofs.is_empty(), "Should have no proofs initially"); // Fund the wallet fund_wallet_repository(&wallet_repository, &mint_url, 100.into()).await; // Check proofs again let proofs = wallet_repository.list_proofs().await.unwrap(); let mint_proofs = proofs .get(&WalletKey::new(mint_url.clone(), CurrencyUnit::Sat)) .cloned() .unwrap_or_default(); assert!(!mint_proofs.is_empty(), "Should have proofs after minting"); // Verify proof total matches balance let proof_total: Amount = mint_proofs.total_amount().unwrap(); assert_eq!(proof_total, 100.into(), "Proof total should be 100 sats"); } /// Test mint management functions (add_mint, remove_wallet, has_mint) /// /// This test verifies: /// 1. has_mint returns false for unknown mints /// 2. add_mint adds the mint /// 3. has_mint returns true after adding /// 4. remove_wallet removes the mint /// 5. has_mint returns false after removal #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_wallet_repository_mint_management() { let wallet_repository = create_test_wallet_repository().await; let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); // Initially mint should not be in wallet assert!( !wallet_repository.has_mint(&mint_url).await, "Mint should not be in wallet initially" ); // Add the mint wallet_repository .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); // Now mint should be in wallet assert!( wallet_repository.has_mint(&mint_url).await, "Mint should be in wallet after adding" ); // Get wallets should include this mint let wallets = wallet_repository.get_wallets().await; assert!(!wallets.is_empty(), "Should have at least one wallet"); // Get specific wallet let wallet = wallet_repository .get_wallet(&mint_url, &CurrencyUnit::Sat) .await; assert!(wallet.is_ok(), "Should be able to get wallet for mint"); // Get wallets for this mint let mint_wallets = wallet_repository.get_wallets_for_mint(&mint_url).await; // Remove all wallets for the mint for wallet in mint_wallets { wallet_repository .remove_wallet(mint_url.clone(), wallet.unit.clone()) .await .unwrap(); } // Now mint should not be in wallet assert!( !wallet_repository.has_mint(&mint_url).await, "Mint should not be in wallet after removal" ); } /// Test check_all_mint_quotes() function /// /// This test verifies: /// 1. Create a mint quote /// 2. Pay the quote /// 3. Poll until quote is paid (like a real wallet would) /// 4. check_all_mint_quotes() processes paid quotes #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_wallet_repository_check_all_mint_quotes() { let wallet_repository = create_test_wallet_repository().await; let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); wallet_repository .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); let wallet = wallet_repository .get_wallet(&mint_url, &CurrencyUnit::Sat) .await .unwrap(); // Create a mint quote let mint_quote = wallet .mint_quote( PaymentMethod::Known(KnownMethod::Bolt11), Some(100.into()), None, None, ) .await .unwrap(); // Pay the invoice (in regtest mode) - for fake wallet, payment is simulated automatically let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); pay_if_regtest(&get_test_temp_dir(), &invoice) .await .unwrap(); // Poll for quote to be paid (like a real wallet would) let mut quote_status = wallet .refresh_mint_quote_status(&mint_quote.id) .await .unwrap(); let timeout = tokio::time::Duration::from_secs(30); let start = tokio::time::Instant::now(); while quote_status.state != MintQuoteState::Paid && quote_status.state != MintQuoteState::Issued { if start.elapsed() > timeout { panic!( "Timeout waiting for quote to be paid, state: {:?}", quote_status.state ); } tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; quote_status = wallet .refresh_mint_quote_status(&mint_quote.id) .await .unwrap(); } tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; let _ = wallet .refresh_mint_quote_status(&mint_quote.id) .await .unwrap(); // Check all mint quotes - this should find the paid quote and mint let minted_amount = wallet_repository .check_all_mint_quotes(Some(mint_url.clone())) .await .unwrap(); assert_eq!( minted_amount, 100.into(), "Should mint 100 sats from paid quote" ); // Verify balance let balances = wallet_repository.total_balance().await.unwrap(); let balance = balances .get(&CurrencyUnit::Sat) .copied() .unwrap_or(Amount::ZERO); assert_eq!(balance, 100.into(), "Balance should be 100 sats"); } /// Test restore() function /// /// This test verifies: /// 1. Create and fund a wallet with a specific seed /// 2. Create a new wallet with the same seed /// 3. Call restore() to recover the proofs #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_wallet_repository_restore() { let seed = Mnemonic::generate(12).unwrap().to_seed_normalized(""); let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); // Create first wallet and fund it { let localstore = Arc::new(memory::empty().await.unwrap()); let wallet1 = WalletRepositoryBuilder::new() .localstore(localstore) .seed(seed) .build() .await .expect("failed to create wallet"); wallet1 .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); let funded = fund_wallet_repository(&wallet1, &mint_url, 100.into()).await; assert_eq!(funded, 100.into()); } // wallet1 goes out of scope // Create second wallet with same seed but fresh storage let localstore2 = Arc::new(memory::empty().await.unwrap()); let wallet2 = WalletRepositoryBuilder::new() .localstore(localstore2) .seed(seed) .build() .await .expect("failed to create wallet"); wallet2 .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); // Initially should have no balance let balances_before = wallet2.total_balance().await.unwrap(); let balance_before = balances_before .get(&CurrencyUnit::Sat) .copied() .unwrap_or(Amount::ZERO); assert_eq!(balance_before, Amount::ZERO, "Should start with no balance"); // Restore from mint using the individual wallet let wallet = wallet2 .get_wallet(&mint_url, &CurrencyUnit::Sat) .await .unwrap(); let restored = wallet.restore().await.unwrap(); assert_eq!(restored.unspent, 100.into(), "Should restore 100 sats"); } /// Test melt_with_mint() with explicit mint selection /// /// This test verifies: /// 1. Fund wallet /// 2. Create melt quote at specific mint /// 3. Execute melt() /// 4. Verify payment succeeded #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_wallet_repository_melt_with_mint() { let wallet_repository = create_test_wallet_repository().await; let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); wallet_repository .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); // Fund the wallet fund_wallet_repository(&wallet_repository, &mint_url, 100.into()).await; // Create an invoice to pay let invoice = create_invoice_for_env(Some(50)).await.unwrap(); // Get wallet for operations let wallet = wallet_repository .get_wallet(&mint_url, &CurrencyUnit::Sat) .await .unwrap(); // Create melt quote at specific mint let melt_quote = wallet .melt_quote( PaymentMethod::Known(KnownMethod::Bolt11), invoice.to_string(), None, None, ) .await .unwrap(); let melt_result = wallet .prepare_melt(&melt_quote.id, std::collections::HashMap::new()) .await .unwrap() .confirm() .await .unwrap(); assert_eq!( melt_result.state(), MeltQuoteState::Paid, "Melt should be paid" ); // Check melt quote status let quote_status = wallet .check_melt_quote_status(&melt_quote.id) .await .unwrap(); assert_eq!( quote_status.state, MeltQuoteState::Paid, "Quote status should be paid" ); } /// Test list_transactions() function /// /// This test verifies: /// 1. Initially no transactions /// 2. After minting, transaction is recorded /// 3. After melting, transaction is recorded #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_wallet_repository_list_transactions() { let wallet_repository = create_test_wallet_repository().await; let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); wallet_repository .add_wallet(mint_url.clone()) .await .expect("failed to add mint"); // Fund the wallet (this creates a mint transaction) fund_wallet_repository(&wallet_repository, &mint_url, 100.into()).await; // List all transactions let transactions = wallet_repository.list_transactions(None).await.unwrap(); assert!( !transactions.is_empty(), "Should have at least one transaction after minting" ); // Get wallet for melt operations let wallet = wallet_repository .get_wallet(&mint_url, &CurrencyUnit::Sat) .await .unwrap(); // Create an invoice and melt (this creates a melt transaction) let invoice = create_invoice_for_env(Some(50)).await.unwrap(); let melt_quote = wallet .melt_quote( PaymentMethod::Known(KnownMethod::Bolt11), invoice.to_string(), None, None, ) .await .unwrap(); wallet .prepare_melt(&melt_quote.id, std::collections::HashMap::new()) .await .unwrap() .confirm() .await .unwrap(); // List transactions again let transactions_after = wallet_repository.list_transactions(None).await.unwrap(); assert!( transactions_after.len() > transactions.len(), "Should have more transactions after melt" ); }