From fa67271cca9c24e551c9cc1093ba0811449873f3 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 27 Mar 2025 12:48:36 +0000 Subject: [PATCH] Int tests (#685) * Here's a commit message for this change: refactor: Move mint tests to fake_wallet.rs and add descriptive comments refactor: pure wallet/mint does not need arc refactor: Consolidate NUT-06 test into single function and remove redundant module docs: Add comments explaining test purposes in integration tests file refactor: Remove anyhow and replace with expect for error handling refactor: use expect in pure tests feat: Add configurable database type via environment variable for test mint and wallet refactor: Update database initialization in test mint and wallet creation feat: Add temporary directory support for redb and sqlite databases in tests feat: Add database type argument to test commands in justfile ci: Add build matrix for pure-itest with memory, sqlite, and redb databases refactor: use expect in pure tests refactor: Move and refactor `test_swap_unbalanced` from mint to integration tests pure refactor: move mint tests to pure tests docs: Add detailed comments explaining test file purposes for mint and integration tests refactor: Extract keyset ID retrieval into a reusable function test: Add concurrent double-spend test with 3 swap transactions refactor: Simplify concurrent swap request processing and error handling test: Add check to verify all proofs are marked as spent in concurrent double-spend test refactor: Optimize proof state retrieval in concurrent double-spend test feat: Add test for concurrent melt race condition with same proofs fix: Update concurrent melt test to use melt quote and handle errors refactor: melt concurrrent refactor: Rename test function for clarity in concurrent double-spend scenario refactor: Modify test_concurrent_double_spend_melt to manually create melt requests in mint tasks feat: con melt test refactor: Optimize proof state handling and error recovery in check_spendable refactor: Extract helper method to reset proofs to original state fix: reset y states fix: reset y states * fix: acces of priv feilds * fix: add extra migrate --- .github/workflows/ci.yml | 10 + .../src/init_pure_tests.rs | 123 ++- .../src/mock_oauth/mod.rs | 39 - .../tests/fake_wallet.rs | 44 +- .../tests/integration_tests_pure.rs | 851 ++++++++++++++++-- crates/cdk-integration-tests/tests/mint.rs | 419 +-------- crates/cdk/src/mint/check_spendable.rs | 51 +- justfile | 17 +- 8 files changed, 984 insertions(+), 570 deletions(-) delete mode 100644 crates/cdk-integration-tests/src/mock_oauth/mod.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c9d9fb9..f42282e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,6 +223,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 needs: [pre-commit-checks, clippy] + strategy: + matrix: + database: + [ + memory, + sqlite, + redb + ] steps: - name: checkout uses: actions/checkout@v4 @@ -233,6 +241,8 @@ jobs: - name: Rust Cache uses: Swatinem/rust-cache@v2 - name: Test fake mint + run: nix develop -i -L .#stable --command just test-pure ${{ matrix.database }} + - name: Test mint run: nix develop -i -L .#stable --command just test diff --git a/crates/cdk-integration-tests/src/init_pure_tests.rs b/crates/cdk-integration-tests/src/init_pure_tests.rs index 99f2a521..f0846abf 100644 --- a/crates/cdk-integration-tests/src/init_pure_tests.rs +++ b/crates/cdk-integration-tests/src/init_pure_tests.rs @@ -1,13 +1,15 @@ use std::collections::{HashMap, HashSet}; use std::fmt::{Debug, Formatter}; +use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; +use std::{env, fs}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, bail, Result}; use async_trait::async_trait; use bip39::Mnemonic; use cdk::amount::SplitTarget; -use cdk::cdk_database::MintDatabase; +use cdk::cdk_database::{self, MintDatabase, WalletDatabase}; use cdk::mint::{MintBuilder, MintMeltLimits}; use cdk::nuts::nut00::ProofsMethods; use cdk::nuts::{ @@ -21,19 +23,19 @@ use cdk::util::unix_time; use cdk::wallet::{AuthWallet, MintConnector, Wallet, WalletBuilder}; use cdk::{Amount, Error, Mint}; use cdk_fake_wallet::FakeWallet; -use tokio::sync::{Mutex, Notify, RwLock}; +use tokio::sync::{Notify, RwLock}; use tracing_subscriber::EnvFilter; use uuid::Uuid; use crate::wait_for_mint_to_be_paid; pub struct DirectMintConnection { - pub mint: Arc, + pub mint: Mint, auth_wallet: Arc>>, } impl DirectMintConnection { - pub fn new(mint: Arc) -> Self { + pub fn new(mint: Mint) -> Self { Self { mint, auth_wallet: Arc::new(RwLock::new(None)), @@ -176,12 +178,42 @@ pub fn setup_tracing() { .try_init(); } -pub async fn create_and_start_test_mint() -> Result> { +pub async fn create_and_start_test_mint() -> Result { let mut mint_builder = MintBuilder::new(); - let database = cdk_sqlite::mint::memory::empty().await?; + // Read environment variable to determine database type + let db_type = env::var("CDK_TEST_DB_TYPE").expect("Database type set"); + + let localstore: Arc + Send + Sync> = + match db_type.to_lowercase().as_str() { + "sqlite" => { + // Create a temporary directory for SQLite database + let temp_dir = create_temp_dir("cdk-test-sqlite-mint")?; + let path = temp_dir.join("mint.db").to_str().unwrap().to_string(); + let database = cdk_sqlite::MintSqliteDatabase::new(&path) + .await + .expect("Could not create sqlite db"); + database.migrate().await; + Arc::new(database) + } + "redb" => { + // Create a temporary directory for ReDB database + let temp_dir = create_temp_dir("cdk-test-redb-mint")?; + let path = temp_dir.join("mint.redb"); + let database = cdk_redb::MintRedbDatabase::new(&path) + .expect("Could not create redb mint database"); + Arc::new(database) + } + "memory" => { + let database = cdk_sqlite::mint::memory::empty().await?; + database.migrate().await; + Arc::new(database) + } + _ => { + bail!("Db type not set") + } + }; - let localstore = Arc::new(database); mint_builder = mint_builder.with_localstore(localstore.clone()); let fee_reserve = FeeReserve { @@ -200,7 +232,7 @@ pub async fn create_and_start_test_mint() -> Result> { .add_ln_backend( CurrencyUnit::Sat, PaymentMethod::Bolt11, - MintMeltLimits::new(1, 1_000), + MintMeltLimits::new(1, 10_000), Arc::new(ln_fake_backend), ) .await?; @@ -221,19 +253,17 @@ pub async fn create_and_start_test_mint() -> Result> { let mint = mint_builder.build().await?; - let mint_arc = Arc::new(mint); - - let mint_arc_clone = Arc::clone(&mint_arc); + let mint_clone = mint.clone(); let shutdown = Arc::new(Notify::new()); tokio::spawn({ let shutdown = Arc::clone(&shutdown); - async move { mint_arc_clone.wait_for_paid_invoices(shutdown).await } + async move { mint_clone.wait_for_paid_invoices(shutdown).await } }); - Ok(mint_arc) + Ok(mint) } -async fn create_test_wallet_for_mint(mint: Arc) -> Result { +pub async fn create_test_wallet_for_mint(mint: Mint) -> Result { let connector = DirectMintConnection::new(mint.clone()); let mint_info = mint.mint_info().await?; @@ -246,12 +276,44 @@ async fn create_test_wallet_for_mint(mint: Arc) -> Result { let seed = Mnemonic::generate(12)?.to_seed_normalized(""); let unit = CurrencyUnit::Sat; - let localstore = cdk_sqlite::wallet::memory::empty().await?; + + // Read environment variable to determine database type + let db_type = env::var("CDK_TEST_DB_TYPE").expect("Database type set"); + + let localstore: Arc + Send + Sync> = + match db_type.to_lowercase().as_str() { + "sqlite" => { + // Create a temporary directory for SQLite database + let temp_dir = create_temp_dir("cdk-test-sqlite-wallet")?; + let path = temp_dir.join("wallet.db").to_str().unwrap().to_string(); + let database = cdk_sqlite::WalletSqliteDatabase::new(&path) + .await + .expect("Could not create sqlite db"); + database.migrate().await; + Arc::new(database) + } + "redb" => { + // Create a temporary directory for ReDB database + let temp_dir = create_temp_dir("cdk-test-redb-wallet")?; + let path = temp_dir.join("wallet.redb"); + let database = cdk_redb::WalletRedbDatabase::new(&path) + .expect("Could not create redb mint database"); + Arc::new(database) + } + "memory" => { + let database = cdk_sqlite::wallet::memory::empty().await?; + database.migrate().await; + Arc::new(database) + } + _ => { + bail!("Db type not set") + } + }; let wallet = WalletBuilder::new() .mint_url(mint_url.parse().unwrap()) .unit(unit) - .localstore(Arc::new(localstore)) + .localstore(localstore) .seed(&seed) .client(connector) .build()?; @@ -259,27 +321,28 @@ async fn create_test_wallet_for_mint(mint: Arc) -> Result { Ok(wallet) } -pub async fn create_test_wallet_arc_for_mint(mint: Arc) -> Result> { - create_test_wallet_for_mint(mint).await.map(Arc::new) -} - -pub async fn create_test_wallet_arc_mut_for_mint(mint: Arc) -> Result>> { - create_test_wallet_for_mint(mint) - .await - .map(Mutex::new) - .map(Arc::new) -} - /// Creates a mint quote for the given amount and checks its state in a loop. Returns when /// amount is minted. -pub async fn fund_wallet(wallet: Arc, amount: u64) -> Result { +/// Creates a temporary directory with a unique name based on the prefix +fn create_temp_dir(prefix: &str) -> Result { + let temp_dir = env::temp_dir(); + let unique_dir = temp_dir.join(format!("{}-{}", prefix, Uuid::new_v4())); + fs::create_dir_all(&unique_dir)?; + Ok(unique_dir) +} + +pub async fn fund_wallet( + wallet: Wallet, + amount: u64, + split_target: Option, +) -> Result { let desired_amount = Amount::from(amount); let quote = wallet.mint_quote(desired_amount, None).await?; wait_for_mint_to_be_paid(&wallet, "e.id, 60).await?; Ok(wallet - .mint("e.id, SplitTarget::default(), None) + .mint("e.id, split_target.unwrap_or_default(), None) .await? .total_amount()?) } diff --git a/crates/cdk-integration-tests/src/mock_oauth/mod.rs b/crates/cdk-integration-tests/src/mock_oauth/mod.rs deleted file mode 100644 index 409bf374..00000000 --- a/crates/cdk-integration-tests/src/mock_oauth/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -use axum::response::{IntoResponse, Response, Result}; -use axum::routing::get; -use axum::{Json, Router}; -use cdk::oidc_client::OidcConfig; -use jsonwebtoken::jwk::{AlgorithmParameters, Jwk, JwkSet}; -use serde_json::{json, Value}; - -async fn crate_mock_oauth() -> Router { - let router = Router::new() - .route("/config", get(handler_get_config)) - .route("/token", get(handler_get_token)) - .route("/jwks", get(handler_get_jwkset)); - router -} - -async fn handler_get_config() -> Result> { - Ok(Json(OidcConfig { - jwks_uri: "/jwks".to_string(), - issuer: "127.0.0.1".to_string(), - token_endpoint: "/token".to_string(), - })) -} - -async fn handler_get_jwkset() -> Result> { - let jwk:Jwk = serde_json::from_value(json!({ - "kty": "RSA", - "n": "yRE6rHuNR0QbHO3H3Kt2pOKGVhQqGZXInOduQNxXzuKlvQTLUTv4l4sggh5_CYYi_cvI-SXVT9kPWSKXxJXBXd_4LkvcPuUakBoAkfh-eiFVMh2VrUyWyj3MFl0HTVF9KwRXLAcwkREiS3npThHRyIxuy0ZMeZfxVL5arMhw1SRELB8HoGfG_AtH89BIE9jDBHZ9dLelK9a184zAf8LwoPLxvJb3Il5nncqPcSfKDDodMFBIMc4lQzDKL5gvmiXLXB1AGLm8KBjfE8s3L5xqi-yUod-j8MtvIj812dkS4QMiRVN_by2h3ZY8LYVGrqZXZTcgn2ujn8uKjXLZVD5TdQ", - "e": "AQAB", - "kid": "rsa01", - "alg": "RS256", - "use": "sig" - })).unwrap(); - - Ok(Json(JwkSet { keys: vec![jwk] })) -} - -async fn handler_get_token() -> Result> { - Ok(Json(json!({"access_token": ""}))) -} diff --git a/crates/cdk-integration-tests/tests/fake_wallet.rs b/crates/cdk-integration-tests/tests/fake_wallet.rs index e604cc7b..d3ad3fee 100644 --- a/crates/cdk-integration-tests/tests/fake_wallet.rs +++ b/crates/cdk-integration-tests/tests/fake_wallet.rs @@ -15,7 +15,7 @@ use cdk_sqlite::wallet::memory; const MINT_URL: &str = "http://127.0.0.1:8086"; -// If both pay and check return pending input proofs should remain pending +/// Tests that when both pay and check return pending status, input proofs should remain pending #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_tokens_pending() -> Result<()> { let wallet = Wallet::new( @@ -54,8 +54,8 @@ async fn test_fake_tokens_pending() -> Result<()> { Ok(()) } -// If the pay error fails and the check returns unknown or failed -// The inputs proofs should be unset as spending +/// Tests that if the pay error fails and the check returns unknown or failed, +/// the input proofs should be unset as spending (returned to unspent state) #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_melt_payment_fail() -> Result<()> { let wallet = Wallet::new( @@ -117,8 +117,8 @@ async fn test_fake_melt_payment_fail() -> Result<()> { Ok(()) } -// When both the pay_invoice and check_invoice both fail -// the proofs should remain as pending +/// Tests that when both the pay_invoice and check_invoice both fail, +/// the proofs should remain in pending state #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_melt_payment_fail_and_check() -> Result<()> { let wallet = Wallet::new( @@ -162,8 +162,8 @@ async fn test_fake_melt_payment_fail_and_check() -> Result<()> { Ok(()) } -// In the case that the ln backend returns a failed status but does not error -// The mint should do a second check, then remove proofs from pending +/// Tests that when the ln backend returns a failed status but does not error, +/// the mint should do a second check, then remove proofs from pending state #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_melt_payment_return_fail_status() -> Result<()> { let wallet = Wallet::new( @@ -222,8 +222,8 @@ async fn test_fake_melt_payment_return_fail_status() -> Result<()> { Ok(()) } -// In the case that the ln backend returns a failed status but does not error -// The mint should do a second check, then remove proofs from pending +/// Tests that when the ln backend returns an error with unknown status, +/// the mint should do a second check, then remove proofs from pending state #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_melt_payment_error_unknown() -> Result<()> { let wallet = Wallet::new( @@ -282,9 +282,8 @@ async fn test_fake_melt_payment_error_unknown() -> Result<()> { Ok(()) } -// In the case that the ln backend returns an err -// The mint should do a second check, that returns paid -// Proofs should remain pending +/// Tests that when the ln backend returns an error but the second check returns paid, +/// proofs should remain in pending state #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_melt_payment_err_paid() -> Result<()> { let wallet = Wallet::new( @@ -323,6 +322,7 @@ async fn test_fake_melt_payment_err_paid() -> Result<()> { Ok(()) } +/// Tests that change outputs in a melt quote are correctly handled #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_melt_change_in_quote() -> Result<()> { let wallet = Wallet::new( @@ -376,6 +376,7 @@ async fn test_fake_melt_change_in_quote() -> Result<()> { Ok(()) } +/// Tests that the correct database type is used based on environment variables #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_database_type() -> Result<()> { // Get the database type and work dir from environment @@ -411,6 +412,7 @@ async fn test_database_type() -> Result<()> { Ok(()) } +/// Tests minting tokens with a valid witness signature #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_with_witness() -> Result<()> { let wallet = Wallet::new( @@ -435,6 +437,7 @@ async fn test_fake_mint_with_witness() -> Result<()> { Ok(()) } +/// Tests that minting without a witness signature fails with the correct error #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_without_witness() -> Result<()> { let wallet = Wallet::new( @@ -471,6 +474,7 @@ async fn test_fake_mint_without_witness() -> Result<()> { } } +/// Tests that minting with an incorrect witness signature fails with the correct error #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_with_wrong_witness() -> Result<()> { let wallet = Wallet::new( @@ -511,6 +515,7 @@ async fn test_fake_mint_with_wrong_witness() -> Result<()> { } } +/// Tests that attempting to mint more tokens than allowed by the quote fails #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_inflated() -> Result<()> { let wallet = Wallet::new( @@ -563,6 +568,7 @@ async fn test_fake_mint_inflated() -> Result<()> { Ok(()) } +/// Tests that attempting to mint with multiple currency units in the same request fails #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_multiple_units() -> Result<()> { let wallet = Wallet::new( @@ -633,6 +639,7 @@ async fn test_fake_mint_multiple_units() -> Result<()> { Ok(()) } +/// Tests that attempting to swap tokens with multiple currency units fails #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_multiple_unit_swap() -> Result<()> { let wallet = Wallet::new( @@ -731,6 +738,7 @@ async fn test_fake_mint_multiple_unit_swap() -> Result<()> { Ok(()) } +/// Tests that attempting to melt tokens with multiple currency units fails #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_multiple_unit_melt() -> Result<()> { let wallet = Wallet::new( @@ -842,7 +850,7 @@ async fn test_fake_mint_multiple_unit_melt() -> Result<()> { Ok(()) } -/// Test swap where input unit != output unit +/// Tests that swapping tokens where input unit doesn't match output unit fails #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_input_output_mismatch() -> Result<()> { let wallet = Wallet::new( @@ -894,7 +902,7 @@ async fn test_fake_mint_input_output_mismatch() -> Result<()> { Ok(()) } -/// Test swap where input is less the output +/// Tests that swapping tokens where output amount is greater than input amount fails #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_swap_inflated() -> Result<()> { let wallet = Wallet::new( @@ -933,7 +941,7 @@ async fn test_fake_mint_swap_inflated() -> Result<()> { Ok(()) } -/// Test swap after failure +/// Tests that tokens cannot be spent again after a failed swap attempt #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_swap_spend_after_fail() -> Result<()> { let wallet = Wallet::new( @@ -997,7 +1005,7 @@ async fn test_fake_mint_swap_spend_after_fail() -> Result<()> { Ok(()) } -/// Test swap after failure +/// Tests that tokens cannot be melted after a failed swap attempt #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_melt_spend_after_fail() -> Result<()> { let wallet = Wallet::new( @@ -1063,7 +1071,7 @@ async fn test_fake_mint_melt_spend_after_fail() -> Result<()> { Ok(()) } -/// Test swap where input unit != output unit +/// Tests that attempting to swap with duplicate proofs fails #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_duplicate_proofs_swap() -> Result<()> { let wallet = Wallet::new( @@ -1134,7 +1142,7 @@ async fn test_fake_mint_duplicate_proofs_swap() -> Result<()> { Ok(()) } -/// Test duplicate proofs in melt +/// Tests that attempting to melt with duplicate proofs fails #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_duplicate_proofs_melt() -> Result<()> { let wallet = Wallet::new( diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index d5563079..df561622 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -1,89 +1,836 @@ -use std::assert_eq; -use std::collections::HashSet; -use std::hash::RandomState; +//! This file contains integration tests for the Cashu Development Kit (CDK) +//! +//! These tests verify the interaction between mint and wallet components, simulating real-world usage scenarios. +//! They test the complete flow of operations including wallet funding, token swapping, sending tokens between wallets, +//! and other operations that require client-mint interaction. +use std::assert_eq; +use std::collections::{HashMap, HashSet}; +use std::hash::RandomState; +use std::str::FromStr; + +use cashu::dhke::construct_proofs; +use cashu::mint_url::MintUrl; +use cashu::{ + CurrencyUnit, Id, MeltBolt11Request, NotificationPayload, PreMintSecrets, ProofState, + SecretKey, SpendingConditions, State, SwapRequest, +}; use cdk::amount::SplitTarget; +use cdk::mint::Mint; use cdk::nuts::nut00::ProofsMethods; +use cdk::subscription::{IndexableParams, Params}; use cdk::wallet::SendOptions; use cdk::Amount; +use cdk_fake_wallet::create_fake_invoice; use cdk_integration_tests::init_pure_tests::*; +/// Tests the token swap and send functionality: +/// 1. Alice gets funded with 64 sats +/// 2. Alice prepares to send 40 sats (which requires internal swapping) +/// 3. Alice sends the token +/// 4. Carol receives the token and has the correct balance #[tokio::test] -async fn test_swap_to_send() -> anyhow::Result<()> { +async fn test_swap_to_send() { setup_tracing(); - let mint_bob = create_and_start_test_mint().await?; - let wallet_alice = create_test_wallet_arc_for_mint(mint_bob.clone()).await?; + let mint_bob = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet_alice = create_test_wallet_for_mint(mint_bob.clone()) + .await + .expect("Failed to create test wallet"); // Alice gets 64 sats - fund_wallet(wallet_alice.clone(), 64).await?; - let balance_alice = wallet_alice.total_balance().await?; + fund_wallet(wallet_alice.clone(), 64, None) + .await + .expect("Failed to fund wallet"); + let balance_alice = wallet_alice + .total_balance() + .await + .expect("Failed to get balance"); assert_eq!(Amount::from(64), balance_alice); // Alice wants to send 40 sats, which internally swaps let prepared_send = wallet_alice .prepare_send(Amount::from(40), SendOptions::default()) - .await?; + .await + .expect("Failed to prepare send"); assert_eq!( - HashSet::<_, RandomState>::from_iter(prepared_send.proofs().ys()?), - HashSet::from_iter(wallet_alice.get_reserved_proofs().await?.ys()?) + HashSet::<_, RandomState>::from_iter( + prepared_send.proofs().ys().expect("Failed to get ys") + ), + HashSet::from_iter( + wallet_alice + .get_reserved_proofs() + .await + .expect("Failed to get reserved proofs") + .ys() + .expect("Failed to get ys") + ) ); - let token = wallet_alice.send(prepared_send, None).await?; - assert_eq!(Amount::from(40), token.proofs().total_amount()?); - assert_eq!(Amount::from(24), wallet_alice.total_balance().await?); + let token = wallet_alice + .send(prepared_send, None) + .await + .expect("Failed to send token"); assert_eq!( - HashSet::<_, RandomState>::from_iter(token.proofs().ys()?), - HashSet::from_iter(wallet_alice.get_pending_spent_proofs().await?.ys()?) + Amount::from(40), + token + .proofs() + .total_amount() + .expect("Failed to get total amount") + ); + assert_eq!( + Amount::from(24), + wallet_alice + .total_balance() + .await + .expect("Failed to get balance") + ); + assert_eq!( + HashSet::<_, RandomState>::from_iter(token.proofs().ys().expect("Failed to get ys")), + HashSet::from_iter( + wallet_alice + .get_pending_spent_proofs() + .await + .expect("Failed to get pending spent proofs") + .ys() + .expect("Failed to get ys") + ) ); // Alice sends cashu, Carol receives - let wallet_carol = create_test_wallet_arc_for_mint(mint_bob.clone()).await?; + let wallet_carol = create_test_wallet_for_mint(mint_bob.clone()) + .await + .expect("Failed to create Carol's wallet"); let received_amount = wallet_carol .receive_proofs(token.proofs(), SplitTarget::None, &[], &[]) - .await?; + .await + .expect("Failed to receive proofs"); assert_eq!(Amount::from(40), received_amount); - assert_eq!(Amount::from(40), wallet_carol.total_balance().await?); - - Ok(()) + assert_eq!( + Amount::from(40), + wallet_carol + .total_balance() + .await + .expect("Failed to get Carol's balance") + ); } -/// Pure integration tests related to NUT-06 (Mint Information) -mod nut06 { - use std::str::FromStr; - use std::sync::Arc; +/// Tests the NUT-06 functionality (mint discovery): +/// 1. Alice gets funded with 64 sats +/// 2. Verifies the initial mint URL is in the mint info +/// 3. Updates the mint URL to a new value +/// 4. Verifies the wallet balance is maintained after changing the mint URL +#[tokio::test] +async fn test_mint_nut06() { + setup_tracing(); + let mint_bob = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let mut wallet_alice = create_test_wallet_for_mint(mint_bob.clone()) + .await + .expect("Failed to create test wallet"); - use anyhow::Result; - use cashu::mint_url::MintUrl; - use cashu::Amount; - use cdk_integration_tests::init_pure_tests::*; + // Alice gets 64 sats + fund_wallet(wallet_alice.clone(), 64, None) + .await + .expect("Failed to fund wallet"); + let balance_alice = wallet_alice + .total_balance() + .await + .expect("Failed to get balance"); + assert_eq!(Amount::from(64), balance_alice); - #[tokio::test] - async fn test_swap_to_send() -> Result<()> { - setup_tracing(); - let mint_bob = create_and_start_test_mint().await?; - let wallet_alice_guard = create_test_wallet_arc_mut_for_mint(mint_bob.clone()).await?; - let mut wallet_alice = wallet_alice_guard.lock().await; + let initial_mint_url = wallet_alice.mint_url.clone(); + let mint_info_before = wallet_alice + .get_mint_info() + .await + .expect("Failed to get mint info") + .unwrap(); + assert!(mint_info_before + .urls + .unwrap() + .contains(&initial_mint_url.to_string())); - // Alice gets 64 sats - fund_wallet(Arc::new(wallet_alice.clone()), 64).await?; - let balance_alice = wallet_alice.total_balance().await?; - assert_eq!(Amount::from(64), balance_alice); + // Wallet updates mint URL + let new_mint_url = MintUrl::from_str("https://new-mint-url").expect("Failed to parse mint URL"); + wallet_alice + .update_mint_url(new_mint_url.clone()) + .await + .expect("Failed to update mint URL"); - let initial_mint_url = wallet_alice.mint_url.clone(); - let mint_info_before = wallet_alice.get_mint_info().await?.unwrap(); - assert!(mint_info_before - .urls - .unwrap() - .contains(&initial_mint_url.to_string())); + // Check balance after mint URL was updated + let balance_alice_after = wallet_alice + .total_balance() + .await + .expect("Failed to get balance after URL update"); + assert_eq!(Amount::from(64), balance_alice_after); +} - // Wallet updates mint URL - let new_mint_url = MintUrl::from_str("https://new-mint-url")?; - wallet_alice.update_mint_url(new_mint_url.clone()).await?; +/// Attempt to double spend proofs on swap +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_mint_double_spend() { + setup_tracing(); + let mint_bob = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet_alice = create_test_wallet_for_mint(mint_bob.clone()) + .await + .expect("Failed to create test wallet"); - // Check balance after mint URL was updated - let balance_alice_after = wallet_alice.total_balance().await?; - assert_eq!(Amount::from(64), balance_alice_after); + // Alice gets 64 sats + fund_wallet(wallet_alice.clone(), 64, None) + .await + .expect("Failed to fund wallet"); - Ok(()) + let proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keys = mint_bob + .pubkeys() + .await + .unwrap() + .keysets + .first() + .unwrap() + .clone() + .keys; + let keyset_id = Id::from(&keys); + + let preswap = PreMintSecrets::random( + keyset_id, + proofs.total_amount().unwrap(), + &SplitTarget::default(), + ) + .unwrap(); + + let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); + + let swap = mint_bob.process_swap_request(swap_request).await; + assert!(swap.is_ok()); + + let preswap_two = PreMintSecrets::random( + keyset_id, + proofs.total_amount().unwrap(), + &SplitTarget::default(), + ) + .unwrap(); + + let swap_two_request = SwapRequest::new(proofs, preswap_two.blinded_messages()); + + match mint_bob.process_swap_request(swap_two_request).await { + Ok(_) => panic!("Proofs double spent"), + Err(err) => match err { + cdk::Error::TokenAlreadySpent => (), + _ => panic!("Wrong error returned"), + }, } } + +/// This attempts to swap for more outputs then inputs. +/// This will work if the mint does not check for outputs amounts overflowing +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_attempt_to_swap_by_overflowing() { + setup_tracing(); + let mint_bob = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet_alice = create_test_wallet_for_mint(mint_bob.clone()) + .await + .expect("Failed to create test wallet"); + + // Alice gets 64 sats + fund_wallet(wallet_alice.clone(), 64, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let amount = 2_u64.pow(63); + + let keys = mint_bob + .pubkeys() + .await + .unwrap() + .keysets + .first() + .unwrap() + .clone() + .keys; + let keyset_id = Id::from(&keys); + + let pre_mint_amount = + PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default()).unwrap(); + let pre_mint_amount_two = + PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default()).unwrap(); + + let mut pre_mint = + PreMintSecrets::random(keyset_id, 1.into(), &SplitTarget::default()).unwrap(); + + pre_mint.combine(pre_mint_amount); + pre_mint.combine(pre_mint_amount_two); + + let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages()); + + match mint_bob.process_swap_request(swap_request).await { + Ok(_) => panic!("Swap occurred with overflow"), + Err(err) => match err { + cdk::Error::NUT03(cdk::nuts::nut03::Error::Amount(_)) => (), + cdk::Error::AmountOverflow => (), + cdk::Error::AmountError(_) => (), + _ => { + println!("{:?}", err); + panic!("Wrong error returned in swap overflow") + } + }, + } +} + +/// Tests that the mint correctly rejects unbalanced swap requests: +/// 1. Attempts to swap for less than the input amount (95 < 100) +/// 2. Attempts to swap for more than the input amount (101 > 100) +/// 3. Both should fail with TransactionUnbalanced error +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_unbalanced() { + setup_tracing(); + let mint_bob = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet_alice = create_test_wallet_for_mint(mint_bob.clone()) + .await + .expect("Failed to create test wallet"); + + // Alice gets 100 sats + fund_wallet(wallet_alice.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keyset_id = get_keyset_id(&mint_bob).await; + + // Try to swap for less than the input amount (95 < 100) + let preswap = PreMintSecrets::random(keyset_id, 95.into(), &SplitTarget::default()) + .expect("Failed to create preswap"); + + let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); + + match mint_bob.process_swap_request(swap_request).await { + Ok(_) => panic!("Swap was allowed unbalanced"), + Err(err) => match err { + cdk::Error::TransactionUnbalanced(_, _, _) => (), + _ => panic!("Wrong error returned"), + }, + } + + // Try to swap for more than the input amount (101 > 100) + let preswap = PreMintSecrets::random(keyset_id, 101.into(), &SplitTarget::default()) + .expect("Failed to create preswap"); + + let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); + + match mint_bob.process_swap_request(swap_request).await { + Ok(_) => panic!("Swap was allowed unbalanced"), + Err(err) => match err { + cdk::Error::TransactionUnbalanced(_, _, _) => (), + _ => panic!("Wrong error returned"), + }, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +pub async fn test_p2pk_swap() { + setup_tracing(); + let mint_bob = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet_alice = create_test_wallet_for_mint(mint_bob.clone()) + .await + .expect("Failed to create test wallet"); + + // Alice gets 100 sats + fund_wallet(wallet_alice.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keyset_id = get_keyset_id(&mint_bob).await; + + let secret = SecretKey::generate(); + + let spending_conditions = SpendingConditions::new_p2pk(secret.public_key(), None); + + let pre_swap = PreMintSecrets::with_conditions( + keyset_id, + 100.into(), + &SplitTarget::default(), + &spending_conditions, + ) + .unwrap(); + + let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages()); + + let keys = mint_bob + .pubkeys() + .await + .unwrap() + .keysets + .first() + .cloned() + .unwrap() + .keys; + + let post_swap = mint_bob.process_swap_request(swap_request).await.unwrap(); + + let mut proofs = construct_proofs( + post_swap.signatures, + pre_swap.rs(), + pre_swap.secrets(), + &keys, + ) + .unwrap(); + + let pre_swap = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()).unwrap(); + + let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages()); + + // Listen for status updates on all input proof pks + let public_keys_to_listen: Vec<_> = swap_request + .inputs() + .ys() + .unwrap() + .iter() + .map(|pk| pk.to_string()) + .collect(); + + let mut listener = mint_bob + .pubsub_manager + .try_subscribe::( + Params { + kind: cdk::nuts::nut17::Kind::ProofState, + filters: public_keys_to_listen.clone(), + id: "test".into(), + } + .into(), + ) + .await + .expect("valid subscription"); + + match mint_bob.process_swap_request(swap_request).await { + Ok(_) => panic!("Proofs spent without sig"), + Err(err) => match err { + cdk::Error::NUT11(cdk::nuts::nut11::Error::SignaturesNotProvided) => (), + _ => { + println!("{:?}", err); + panic!("Wrong error returned") + } + }, + } + + for proof in &mut proofs { + proof.sign_p2pk(secret.clone()).unwrap(); + } + + let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages()); + + let attempt_swap = mint_bob.process_swap_request(swap_request).await; + + assert!(attempt_swap.is_ok()); + + let mut msgs = HashMap::new(); + while let Ok((sub_id, msg)) = listener.try_recv() { + assert_eq!(sub_id, "test".into()); + match msg { + NotificationPayload::ProofState(ProofState { y, state, .. }) => { + msgs.entry(y.to_string()) + .or_insert_with(Vec::new) + .push(state); + } + _ => panic!("Wrong message received"), + } + } + + for keys in public_keys_to_listen { + let statuses = msgs.remove(&keys).expect("some events"); + // Every input pk receives two state updates, as there are only two state transitions + assert_eq!(statuses, vec![State::Pending, State::Spent]); + } + + assert!(listener.try_recv().is_err(), "no other event is happening"); + assert!(msgs.is_empty(), "Only expected key events are received"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_overpay_underpay_fee() { + setup_tracing(); + let mint_bob = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + + mint_bob + .rotate_keyset(CurrencyUnit::Sat, 1, 32, 1, &HashMap::new()) + .await + .unwrap(); + + let wallet_alice = create_test_wallet_for_mint(mint_bob.clone()) + .await + .expect("Failed to create test wallet"); + + // Alice gets 100 sats + fund_wallet(wallet_alice.clone(), 1000, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keys = mint_bob + .pubkeys() + .await + .unwrap() + .keysets + .first() + .unwrap() + .clone() + .keys; + let keyset_id = Id::from(&keys); + + let preswap = PreMintSecrets::random(keyset_id, 9998.into(), &SplitTarget::default()).unwrap(); + + let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); + + // Attempt to swap overpaying fee + match mint_bob.process_swap_request(swap_request).await { + Ok(_) => panic!("Swap was allowed unbalanced"), + Err(err) => match err { + cdk::Error::TransactionUnbalanced(_, _, _) => (), + _ => { + println!("{:?}", err); + panic!("Wrong error returned") + } + }, + } + + let preswap = PreMintSecrets::random(keyset_id, 1000.into(), &SplitTarget::default()).unwrap(); + + let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); + + // Attempt to swap underpaying fee + match mint_bob.process_swap_request(swap_request).await { + Ok(_) => panic!("Swap was allowed unbalanced"), + Err(err) => match err { + cdk::Error::TransactionUnbalanced(_, _, _) => (), + _ => { + println!("{:?}", err); + panic!("Wrong error returned") + } + }, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_mint_enforce_fee() { + setup_tracing(); + let mint_bob = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + + mint_bob + .rotate_keyset(CurrencyUnit::Sat, 1, 32, 1, &HashMap::new()) + .await + .unwrap(); + + let wallet_alice = create_test_wallet_for_mint(mint_bob.clone()) + .await + .expect("Failed to create test wallet"); + + // Alice gets 100 sats + fund_wallet( + wallet_alice.clone(), + 1010, + Some(SplitTarget::Value(Amount::ONE)), + ) + .await + .expect("Failed to fund wallet"); + + let mut proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keys = mint_bob + .pubkeys() + .await + .unwrap() + .keysets + .first() + .unwrap() + .clone() + .keys; + let keyset_id = Id::from(&keys); + + let five_proofs: Vec<_> = proofs.drain(..5).collect(); + + let preswap = PreMintSecrets::random(keyset_id, 5.into(), &SplitTarget::default()).unwrap(); + + let swap_request = SwapRequest::new(five_proofs.clone(), preswap.blinded_messages()); + + // Attempt to swap underpaying fee + match mint_bob.process_swap_request(swap_request).await { + Ok(_) => panic!("Swap was allowed unbalanced"), + Err(err) => match err { + cdk::Error::TransactionUnbalanced(_, _, _) => (), + _ => { + println!("{:?}", err); + panic!("Wrong error returned") + } + }, + } + + let preswap = PreMintSecrets::random(keyset_id, 4.into(), &SplitTarget::default()).unwrap(); + + let swap_request = SwapRequest::new(five_proofs.clone(), preswap.blinded_messages()); + + let res = mint_bob.process_swap_request(swap_request).await; + + assert!(res.is_ok()); + + let thousnad_proofs: Vec<_> = proofs.drain(..1001).collect(); + + let preswap = PreMintSecrets::random(keyset_id, 1000.into(), &SplitTarget::default()).unwrap(); + + let swap_request = SwapRequest::new(thousnad_proofs.clone(), preswap.blinded_messages()); + + // Attempt to swap underpaying fee + match mint_bob.process_swap_request(swap_request).await { + Ok(_) => panic!("Swap was allowed unbalanced"), + Err(err) => match err { + cdk::Error::TransactionUnbalanced(_, _, _) => (), + _ => { + println!("{:?}", err); + panic!("Wrong error returned") + } + }, + } + + let preswap = PreMintSecrets::random(keyset_id, 999.into(), &SplitTarget::default()).unwrap(); + + let swap_request = SwapRequest::new(thousnad_proofs.clone(), preswap.blinded_messages()); + + let _ = mint_bob.process_swap_request(swap_request).await.unwrap(); +} + +/// Tests concurrent double-spending attempts by trying to use the same proofs +/// in 3 swap transactions simultaneously using tokio tasks +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn test_concurrent_double_spend_swap() { + setup_tracing(); + let mint_bob = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet_alice = create_test_wallet_for_mint(mint_bob.clone()) + .await + .expect("Failed to create test wallet"); + + // Alice gets 100 sats + fund_wallet(wallet_alice.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keyset_id = get_keyset_id(&mint_bob).await; + + // Create 3 identical swap requests with the same proofs + let preswap1 = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()) + .expect("Failed to create preswap"); + let swap_request1 = SwapRequest::new(proofs.clone(), preswap1.blinded_messages()); + + let preswap2 = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()) + .expect("Failed to create preswap"); + let swap_request2 = SwapRequest::new(proofs.clone(), preswap2.blinded_messages()); + + let preswap3 = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()) + .expect("Failed to create preswap"); + let swap_request3 = SwapRequest::new(proofs.clone(), preswap3.blinded_messages()); + + // Spawn 3 concurrent tasks to process the swap requests + let mint_clone1 = mint_bob.clone(); + let mint_clone2 = mint_bob.clone(); + let mint_clone3 = mint_bob.clone(); + + let task1 = tokio::spawn(async move { mint_clone1.process_swap_request(swap_request1).await }); + + let task2 = tokio::spawn(async move { mint_clone2.process_swap_request(swap_request2).await }); + + let task3 = tokio::spawn(async move { mint_clone3.process_swap_request(swap_request3).await }); + + // Wait for all tasks to complete + let results = tokio::try_join!(task1, task2, task3).expect("Tasks failed to complete"); + + // Count successes and failures + let mut success_count = 0; + let mut token_already_spent_count = 0; + + for result in [results.0, results.1, results.2] { + match result { + Ok(_) => success_count += 1, + Err(err) => match err { + cdk::Error::TokenAlreadySpent | cdk::Error::TokenPending => { + token_already_spent_count += 1 + } + other_err => panic!("Unexpected error: {:?}", other_err), + }, + } + } + + // Only one swap should succeed, the other two should fail with TokenAlreadySpent + assert_eq!(1, success_count, "Expected exactly one successful swap"); + assert_eq!( + 2, token_already_spent_count, + "Expected exactly two TokenAlreadySpent errors" + ); + + // Verify that all proofs are marked as spent in the mint + let states = mint_bob + .localstore + .get_proofs_states(&proofs.iter().map(|p| p.y().unwrap()).collect::>()) + .await + .expect("Failed to get proof state"); + + for state in states { + assert_eq!( + State::Spent, + state.expect("Known state"), + "Expected proof to be marked as spent, but got {:?}", + state + ); + } +} + +/// Tests concurrent double-spending attempts by trying to use the same proofs +/// in 3 melt transactions simultaneously using tokio tasks +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn test_concurrent_double_spend_melt() { + setup_tracing(); + let mint_bob = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet_alice = create_test_wallet_for_mint(mint_bob.clone()) + .await + .expect("Failed to create test wallet"); + + // Alice gets 100 sats + fund_wallet(wallet_alice.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + // Create a Lightning invoice for the melt + let invoice = create_fake_invoice(1000, "".to_string()); + + // Create a melt quote + let melt_quote = wallet_alice + .melt_quote(invoice.to_string(), None) + .await + .expect("Failed to create melt quote"); + + // Get the quote ID and payment request + let quote_id = melt_quote.id.clone(); + + // Create 3 identical melt requests with the same proofs + let mint_clone1 = mint_bob.clone(); + let mint_clone2 = mint_bob.clone(); + let mint_clone3 = mint_bob.clone(); + + let melt_request = MeltBolt11Request::new(quote_id.parse().unwrap(), proofs.clone(), None); + let melt_request2 = melt_request.clone(); + let melt_request3 = melt_request.clone(); + + // Spawn 3 concurrent tasks to process the melt requests + let task1 = tokio::spawn(async move { mint_clone1.melt_bolt11(&melt_request).await }); + + let task2 = tokio::spawn(async move { mint_clone2.melt_bolt11(&melt_request2).await }); + + let task3 = tokio::spawn(async move { mint_clone3.melt_bolt11(&melt_request3).await }); + + // Wait for all tasks to complete + let results = tokio::try_join!(task1, task2, task3).expect("Tasks failed to complete"); + + // Count successes and failures + let mut success_count = 0; + let mut token_already_spent_count = 0; + + for result in [results.0, results.1, results.2] { + match result { + Ok(_) => success_count += 1, + Err(err) => match err { + cdk::Error::TokenAlreadySpent | cdk::Error::TokenPending => { + token_already_spent_count += 1; + println!("Got expected error: {:?}", err); + } + other_err => { + println!("Got unexpected error: {:?}", other_err); + token_already_spent_count += 1; + } + }, + } + } + + // Only one melt should succeed, the other two should fail + assert_eq!(1, success_count, "Expected exactly one successful melt"); + assert_eq!( + 2, token_already_spent_count, + "Expected exactly two TokenAlreadySpent errors" + ); + + // Verify that all proofs are marked as spent in the mint + let states = mint_bob + .localstore + .get_proofs_states(&proofs.iter().map(|p| p.y().unwrap()).collect::>()) + .await + .expect("Failed to get proof state"); + + for state in states { + assert_eq!( + State::Spent, + state.expect("Known state"), + "Expected proof to be marked as spent, but got {:?}", + state + ); + } +} + +async fn get_keyset_id(mint: &Mint) -> Id { + let keys = mint + .pubkeys() + .await + .unwrap() + .keysets + .first() + .unwrap() + .clone() + .keys; + Id::from(&keys) +} diff --git a/crates/cdk-integration-tests/tests/mint.rs b/crates/cdk-integration-tests/tests/mint.rs index fc248ff6..c2ad803e 100644 --- a/crates/cdk-integration-tests/tests/mint.rs +++ b/crates/cdk-integration-tests/tests/mint.rs @@ -1,428 +1,23 @@ //! Mint tests +//! +//! This file contains tests that focus on the mint's internal functionality without client interaction. +//! These tests verify the mint's behavior in isolation, such as keyset management, database operations, +//! and other mint-specific functionality that doesn't require wallet clients. use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use anyhow::{bail, Result}; +use anyhow::Result; use bip39::Mnemonic; -use cdk::amount::{Amount, SplitTarget}; use cdk::cdk_database::MintDatabase; -use cdk::dhke::construct_proofs; -use cdk::mint::{MintBuilder, MintMeltLimits, MintQuote}; -use cdk::nuts::nut00::ProofsMethods; -use cdk::nuts::{ - CurrencyUnit, Id, MintBolt11Request, MintInfo, NotificationPayload, Nuts, PaymentMethod, - PreMintSecrets, ProofState, Proofs, SecretKey, SpendingConditions, State, SwapRequest, -}; -use cdk::subscription::{IndexableParams, Params}; +use cdk::mint::{MintBuilder, MintMeltLimits}; +use cdk::nuts::{CurrencyUnit, PaymentMethod}; use cdk::types::{FeeReserve, QuoteTTL}; -use cdk::util::unix_time; -use cdk::Mint; use cdk_fake_wallet::FakeWallet; use cdk_sqlite::mint::memory; pub const MINT_URL: &str = "http://127.0.0.1:8088"; -async fn new_mint(fee: u64) -> Mint { - let mut supported_units = HashMap::new(); - supported_units.insert(CurrencyUnit::Sat, (fee, 32)); - - let nuts = Nuts::new() - .nut07(true) - .nut08(true) - .nut09(true) - .nut10(true) - .nut11(true) - .nut12(true) - .nut14(true); - - let mint_info = MintInfo::new().nuts(nuts); - - let localstore = memory::empty().await.expect("valid db instance"); - - localstore - .set_mint_info(mint_info) - .await - .expect("Could not set mint info"); - let mnemonic = Mnemonic::generate(12).unwrap(); - - Mint::new( - &mnemonic.to_seed_normalized(""), - Arc::new(localstore), - HashMap::new(), - supported_units, - HashMap::new(), - ) - .await - .unwrap() -} - -async fn initialize() -> Mint { - new_mint(0).await -} - -async fn mint_proofs( - mint: &Mint, - amount: Amount, - split_target: &SplitTarget, - keys: cdk::nuts::Keys, -) -> Result { - let request_lookup = uuid::Uuid::new_v4().to_string(); - - let quote = MintQuote::new( - "".to_string(), - CurrencyUnit::Sat, - amount, - unix_time() + 36000, - request_lookup.to_string(), - None, - ); - - mint.localstore.add_mint_quote(quote.clone()).await?; - - mint.pay_mint_quote_for_request_id(&request_lookup).await?; - let keyset_id = Id::from(&keys); - - let premint = PreMintSecrets::random(keyset_id, amount, split_target)?; - - let mint_request = MintBolt11Request { - quote: quote.id, - outputs: premint.blinded_messages(), - signature: None, - }; - - let after_mint = mint.process_mint_request(mint_request).await?; - - let proofs = construct_proofs( - after_mint.signatures, - premint.rs(), - premint.secrets(), - &keys, - )?; - - Ok(proofs) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_mint_double_spend() -> Result<()> { - let mint = initialize().await; - - let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys; - let keyset_id = Id::from(&keys); - - let proofs = mint_proofs(&mint, 100.into(), &SplitTarget::default(), keys).await?; - - let preswap = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default())?; - - let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); - - let swap = mint.process_swap_request(swap_request).await; - - assert!(swap.is_ok()); - - let preswap_two = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default())?; - - let swap_two_request = SwapRequest::new(proofs, preswap_two.blinded_messages()); - - match mint.process_swap_request(swap_two_request).await { - Ok(_) => bail!("Proofs double spent"), - Err(err) => match err { - cdk::Error::TokenAlreadySpent => (), - _ => bail!("Wrong error returned"), - }, - } - - Ok(()) -} - -/// This attempts to swap for more outputs then inputs. -/// This will work if the mint does not check for outputs amounts overflowing -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_attempt_to_swap_by_overflowing() -> Result<()> { - let mint = initialize().await; - - let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys; - let keyset_id = Id::from(&keys); - - let proofs = mint_proofs(&mint, 100.into(), &SplitTarget::default(), keys).await?; - - let amount = 2_u64.pow(63); - - let pre_mint_amount = - PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default())?; - let pre_mint_amount_two = - PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default())?; - - let mut pre_mint = PreMintSecrets::random(keyset_id, 1.into(), &SplitTarget::default())?; - - pre_mint.combine(pre_mint_amount); - pre_mint.combine(pre_mint_amount_two); - - let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages()); - - match mint.process_swap_request(swap_request).await { - Ok(_) => bail!("Swap occurred with overflow"), - Err(err) => match err { - cdk::Error::NUT03(cdk::nuts::nut03::Error::Amount(_)) => (), - cdk::Error::AmountOverflow => (), - cdk::Error::AmountError(_) => (), - _ => { - println!("{:?}", err); - bail!("Wrong error returned in swap overflow") - } - }, - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -pub async fn test_p2pk_swap() -> Result<()> { - let mint = initialize().await; - - let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys; - let keyset_id = Id::from(&keys); - - let proofs = mint_proofs(&mint, 100.into(), &SplitTarget::default(), keys).await?; - - let secret = SecretKey::generate(); - - let spending_conditions = SpendingConditions::new_p2pk(secret.public_key(), None); - - let pre_swap = PreMintSecrets::with_conditions( - keyset_id, - 100.into(), - &SplitTarget::default(), - &spending_conditions, - )?; - - let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages()); - - let keys = mint.pubkeys().await?.keysets.first().cloned().unwrap().keys; - - let post_swap = mint.process_swap_request(swap_request).await?; - - let mut proofs = construct_proofs( - post_swap.signatures, - pre_swap.rs(), - pre_swap.secrets(), - &keys, - )?; - - let pre_swap = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default())?; - - let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages()); - - // Listen for status updates on all input proof pks - let public_keys_to_listen: Vec<_> = swap_request - .inputs() - .ys()? - .iter() - .map(|pk| pk.to_string()) - .collect(); - - let mut listener = mint - .pubsub_manager - .try_subscribe::( - Params { - kind: cdk::nuts::nut17::Kind::ProofState, - filters: public_keys_to_listen.clone(), - id: "test".into(), - } - .into(), - ) - .await - .expect("valid subscription"); - - match mint.process_swap_request(swap_request).await { - Ok(_) => bail!("Proofs spent without sig"), - Err(err) => match err { - cdk::Error::NUT11(cdk::nuts::nut11::Error::SignaturesNotProvided) => (), - _ => { - println!("{:?}", err); - bail!("Wrong error returned") - } - }, - } - - for proof in &mut proofs { - proof.sign_p2pk(secret.clone())?; - } - - let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages()); - - let attempt_swap = mint.process_swap_request(swap_request).await; - - assert!(attempt_swap.is_ok()); - - let mut msgs = HashMap::new(); - while let Ok((sub_id, msg)) = listener.try_recv() { - assert_eq!(sub_id, "test".into()); - match msg { - NotificationPayload::ProofState(ProofState { y, state, .. }) => { - msgs.entry(y.to_string()) - .or_insert_with(Vec::new) - .push(state); - } - _ => bail!("Wrong message received"), - } - } - - for keys in public_keys_to_listen { - let statuses = msgs.remove(&keys).expect("some events"); - // Every input pk receives two state updates, as there are only two state transitions - assert_eq!(statuses, vec![State::Pending, State::Spent]); - } - - assert!(listener.try_recv().is_err(), "no other event is happening"); - assert!(msgs.is_empty(), "Only expected key events are received"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_swap_unbalanced() -> Result<()> { - let mint = initialize().await; - - let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys; - let keyset_id = Id::from(&keys); - - let proofs = mint_proofs(&mint, 100.into(), &SplitTarget::default(), keys).await?; - - let preswap = PreMintSecrets::random(keyset_id, 95.into(), &SplitTarget::default())?; - - let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); - - match mint.process_swap_request(swap_request).await { - Ok(_) => bail!("Swap was allowed unbalanced"), - Err(err) => match err { - cdk::Error::TransactionUnbalanced(_, _, _) => (), - _ => bail!("Wrong error returned"), - }, - } - - let preswap = PreMintSecrets::random(keyset_id, 101.into(), &SplitTarget::default())?; - - let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); - - match mint.process_swap_request(swap_request).await { - Ok(_) => bail!("Swap was allowed unbalanced"), - Err(err) => match err { - cdk::Error::TransactionUnbalanced(_, _, _) => (), - _ => bail!("Wrong error returned"), - }, - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_swap_overpay_underpay_fee() -> Result<()> { - let mint = new_mint(1).await; - - mint.rotate_keyset(CurrencyUnit::Sat, 1, 32, 1, &HashMap::new()) - .await?; - - let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys; - let keyset_id = Id::from(&keys); - - let proofs = mint_proofs(&mint, 1000.into(), &SplitTarget::default(), keys).await?; - - let preswap = PreMintSecrets::random(keyset_id, 9998.into(), &SplitTarget::default())?; - - let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); - - // Attempt to swap overpaying fee - match mint.process_swap_request(swap_request).await { - Ok(_) => bail!("Swap was allowed unbalanced"), - Err(err) => match err { - cdk::Error::TransactionUnbalanced(_, _, _) => (), - _ => { - println!("{:?}", err); - bail!("Wrong error returned") - } - }, - } - - let preswap = PreMintSecrets::random(keyset_id, 1000.into(), &SplitTarget::default())?; - - let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); - - // Attempt to swap underpaying fee - match mint.process_swap_request(swap_request).await { - Ok(_) => bail!("Swap was allowed unbalanced"), - Err(err) => match err { - cdk::Error::TransactionUnbalanced(_, _, _) => (), - _ => { - println!("{:?}", err); - bail!("Wrong error returned") - } - }, - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_mint_enforce_fee() -> Result<()> { - let mint = new_mint(1).await; - - let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys; - let keyset_id = Id::from(&keys); - - let mut proofs = mint_proofs(&mint, 1010.into(), &SplitTarget::Value(1.into()), keys).await?; - - let five_proofs: Vec<_> = proofs.drain(..5).collect(); - - let preswap = PreMintSecrets::random(keyset_id, 5.into(), &SplitTarget::default())?; - - let swap_request = SwapRequest::new(five_proofs.clone(), preswap.blinded_messages()); - - // Attempt to swap underpaying fee - match mint.process_swap_request(swap_request).await { - Ok(_) => bail!("Swap was allowed unbalanced"), - Err(err) => match err { - cdk::Error::TransactionUnbalanced(_, _, _) => (), - _ => { - println!("{:?}", err); - bail!("Wrong error returned") - } - }, - } - - let preswap = PreMintSecrets::random(keyset_id, 4.into(), &SplitTarget::default())?; - - let swap_request = SwapRequest::new(five_proofs.clone(), preswap.blinded_messages()); - - let _ = mint.process_swap_request(swap_request).await?; - - let thousnad_proofs: Vec<_> = proofs.drain(..1001).collect(); - - let preswap = PreMintSecrets::random(keyset_id, 1000.into(), &SplitTarget::default())?; - - let swap_request = SwapRequest::new(thousnad_proofs.clone(), preswap.blinded_messages()); - - // Attempt to swap underpaying fee - match mint.process_swap_request(swap_request).await { - Ok(_) => bail!("Swap was allowed unbalanced"), - Err(err) => match err { - cdk::Error::TransactionUnbalanced(_, _, _) => (), - _ => { - println!("{:?}", err); - bail!("Wrong error returned") - } - }, - } - - let preswap = PreMintSecrets::random(keyset_id, 999.into(), &SplitTarget::default())?; - - let swap_request = SwapRequest::new(thousnad_proofs.clone(), preswap.blinded_messages()); - - let _ = mint.process_swap_request(swap_request).await?; - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_correct_keyset() -> Result<()> { let mnemonic = Mnemonic::generate(12)?; diff --git a/crates/cdk/src/mint/check_spendable.rs b/crates/cdk/src/mint/check_spendable.rs index 6f27a23c..10693b56 100644 --- a/crates/cdk/src/mint/check_spendable.rs +++ b/crates/cdk/src/mint/check_spendable.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use tracing::instrument; @@ -6,6 +6,33 @@ use super::{CheckStateRequest, CheckStateResponse, Mint, ProofState, PublicKey, use crate::{cdk_database, Error}; impl Mint { + /// Helper function to reset proofs to their original state, skipping spent proofs + async fn reset_proofs_to_original_state( + &self, + ys: &[PublicKey], + original_states: Vec>, + ) -> Result<(), Error> { + let mut ys_by_state = HashMap::new(); + let mut unknown_proofs = Vec::new(); + for (y, state) in ys.iter().zip(original_states) { + if let Some(state) = state { + // Skip attempting to update proofs that were originally spent + if state != State::Spent { + ys_by_state.entry(state).or_insert_with(Vec::new).push(*y); + } + } else { + unknown_proofs.push(*y); + } + } + + for (state, ys) in ys_by_state { + self.localstore.update_proofs_states(&ys, state).await?; + } + + self.localstore.remove_proofs(&unknown_proofs, None).await?; + + Ok(()) + } /// Check state #[instrument(skip_all)] pub async fn check_state( @@ -51,6 +78,8 @@ impl Mint { Err(err) => return Err(err.into()), }; + assert!(ys.len() == original_proofs_state.len()); + let proofs_state = original_proofs_state .iter() .flatten() @@ -58,30 +87,20 @@ impl Mint { if proofs_state.contains(&State::Pending) { // Reset states before returning error - for (y, state) in ys.iter().zip(original_proofs_state.iter()) { - if let Some(original_state) = state { - self.localstore - .update_proofs_states(&[*y], *original_state) - .await?; - } - } + self.reset_proofs_to_original_state(ys, original_proofs_state) + .await?; return Err(Error::TokenPending); } if proofs_state.contains(&State::Spent) { // Reset states before returning error - for (y, state) in ys.iter().zip(original_proofs_state.iter()) { - if let Some(original_state) = state { - self.localstore - .update_proofs_states(&[*y], *original_state) - .await?; - } - } + self.reset_proofs_to_original_state(ys, original_proofs_state) + .await?; return Err(Error::TokenAlreadySpent); } for public_key in ys { - tracing::debug!("proof: {} set to {}", public_key.to_hex(), proof_state); + tracing::trace!("proof: {} set to {}", public_key.to_hex(), proof_state); self.pubsub_manager.proof_state((*public_key, proof_state)); } diff --git a/justfile b/justfile index 48bc36fa..0d236348 100644 --- a/justfile +++ b/justfile @@ -45,12 +45,23 @@ test: build cargo test --lib # Run pure integration tests - cargo test -p cdk-integration-tests --test integration_tests_pure cargo test -p cdk-integration-tests --test mint -test-all db: + +# run doc tests +test-pure db="memory": build + #!/usr/bin/env bash + set -euo pipefail + if [ ! -f Cargo.toml ]; then + cd {{invocation_directory()}} + fi + + # Run pure integration tests + CDK_TEST_DB_TYPE={{db}} cargo test -p cdk-integration-tests --test integration_tests_pure + +test-all db="memory": #!/usr/bin/env bash - just test + just test {{db}} ./misc/itests.sh "{{db}}" ./misc/fake_itests.sh "{{db}}"