Эх сурвалжийг харах

fix: account for swap fee in select_proofs when force_swap is true (#1626)

* fix: account for swap fee in select_proofs when force_swap is true

When sending with P2PK conditions and no proofs already match,
force_swap=true routes all proofs through a fee-charging swap.
`select_proofs` was called with include_fees=opts.include_fee,
so with the default include_fee=false it selected exactly
the send amount without headroom for the swap fee, causing InsufficientFunds on confirm.
Pass include_fees=opts.include_fee || force_swap
so the iterative fee-convergence loop always runs when every proof will be swapped.
tsk 1 долоо хоног өмнө
parent
commit
5294288cc5

+ 65 - 0
crates/cdk-integration-tests/src/init_pure_tests.rs

@@ -285,6 +285,71 @@ pub async fn create_and_start_test_mint() -> Result<Mint> {
     create_mint_with_limits(None).await
 }
 
+pub async fn create_mint_with_fee(fee_ppk: u64) -> Result<Mint> {
+    // Read environment variable to determine database type
+    let db_type = env::var("CDK_TEST_DB_TYPE").expect("Database type set");
+
+    let localstore = match db_type.to_lowercase().as_str() {
+        "memory" => Arc::new(cdk_sqlite::mint::memory::empty().await?),
+        _ => {
+            // 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();
+            Arc::new(
+                cdk_sqlite::MintSqliteDatabase::new(path.as_str())
+                    .await
+                    .expect("Could not create sqlite db"),
+            )
+        }
+    };
+
+    let mut mint_builder = MintBuilder::new(localstore.clone());
+
+    let fee_reserve = FeeReserve {
+        min_fee_reserve: 1.into(),
+        percent_fee_reserve: 0.02,
+    };
+
+    let ln_fake_backend = FakeWallet::new(
+        fee_reserve.clone(),
+        HashMap::default(),
+        HashSet::default(),
+        2,
+        CurrencyUnit::Sat,
+    );
+
+    mint_builder
+        .add_payment_processor(
+            CurrencyUnit::Sat,
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            MintMeltLimits::new(1, 10_000),
+            Arc::new(ln_fake_backend),
+        )
+        .await?;
+
+    mint_builder.set_unit_fee(&CurrencyUnit::Sat, fee_ppk)?;
+
+    let mnemonic = Mnemonic::generate(12)?;
+
+    mint_builder = mint_builder
+        .with_name("pure test mint".to_string())
+        .with_description("pure test mint".to_string())
+        .with_urls(vec!["https://aaa".to_string()])
+        .with_limits(2000, 2000);
+
+    let quote_ttl = QuoteTTL::new(10000, 10000);
+
+    let mint = mint_builder
+        .build_with_seed(localstore.clone(), &mnemonic.to_seed_normalized(""))
+        .await?;
+
+    mint.set_quote_ttl(quote_ttl).await?;
+
+    mint.start().await?;
+
+    Ok(mint)
+}
+
 pub async fn create_mint_with_limits(limits: Option<(usize, usize)>) -> Result<Mint> {
     // Read environment variable to determine database type
     let db_type = env::var("CDK_TEST_DB_TYPE").expect("Database type set");

+ 188 - 0
crates/cdk-integration-tests/tests/integration_tests_pure.rs

@@ -1308,6 +1308,194 @@ async fn test_concurrent_double_spend_melt() {
     }
 }
 
+/// Tests that P2PK send with force_swap works when the mint charges fees.
+///
+/// When a wallet has no proofs matching the P2PK spending conditions,
+/// `prepare_send` sets `force_swap=true` and re-selects from all proofs.
+/// All selected proofs are routed through a swap (which costs a fee).
+///
+/// Bug: `select_proofs` was called with `include_fees=opts.include_fee`
+/// instead of `include_fees=opts.include_fee || force_swap`, so with the
+/// default `include_fee=false`, proofs were selected without accounting
+/// for the swap fee. The swap then couldn't produce enough output,
+/// causing `InsufficientFunds` during confirm.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_p2pk_send_force_swap_with_fees() {
+    setup_tracing();
+
+    // Create a mint with fee_ppk=1000 (1 sat per input proof)
+    let mint = create_mint_with_fee(1000)
+        .await
+        .expect("Failed to create test mint with fees");
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Fund wallet with 64 sats (normal proofs, no P2PK conditions)
+    fund_wallet(wallet.clone(), 64, None)
+        .await
+        .expect("Failed to fund wallet");
+    assert_eq!(
+        Amount::from(64),
+        wallet.total_balance().await.expect("Failed to get balance")
+    );
+
+    // Generate P2PK spending conditions
+    let secret = SecretKey::generate();
+    let spending_conditions = SpendingConditions::new_p2pk(secret.public_key(), None);
+
+    let send_amount = Amount::from(10);
+
+    // Attempt to send with P2PK conditions (triggers force_swap since no proofs match)
+    let prepared = wallet
+        .prepare_send(
+            send_amount,
+            SendOptions {
+                conditions: Some(spending_conditions),
+                ..Default::default() // include_fee: false
+            },
+        )
+        .await
+        .expect("prepare_send should select enough proofs to cover amount + swap fee");
+
+    let swap_fee = prepared.swap_fee();
+    assert!(
+        swap_fee > Amount::ZERO,
+        "Expected non-zero swap fee for force_swap with fee_ppk=1000"
+    );
+
+    // All proofs should be routed through swap (force_swap=true)
+    assert!(
+        !prepared.proofs_to_swap().is_empty(),
+        "Expected proofs_to_swap to be non-empty for force_swap"
+    );
+    assert!(
+        prepared.proofs_to_send().is_empty(),
+        "Expected proofs_to_send to be empty for force_swap"
+    );
+
+    // Confirm the send — this is where the bug manifests: the swap can't
+    // produce enough output because the selected proofs don't cover the fee
+    let token = prepared
+        .confirm(None)
+        .await
+        .expect("confirm should succeed — swap should produce enough output");
+
+    // Verify token contains exactly the requested amount
+    let keysets_info = wallet.get_mint_keysets().await.unwrap();
+    let token_proofs = token.proofs(&keysets_info).unwrap();
+    assert_eq!(
+        send_amount,
+        token_proofs.total_amount().unwrap(),
+        "Token should contain exactly the send amount"
+    );
+
+    // Verify wallet balance decreased by amount + swap_fee
+    let expected_balance = Amount::from(64) - send_amount - swap_fee;
+    assert_eq!(
+        expected_balance,
+        wallet.total_balance().await.unwrap(),
+        "Wallet balance should be reduced by send amount + swap fee"
+    );
+}
+
+/// Tests that P2PK send with force_swap and include_fee=true works when the
+/// mint charges fees.
+///
+/// Same scenario as above, but with `include_fee: true` so the token includes
+/// enough value for the recipient to pay the redemption fee and receive the
+/// full requested amount.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_p2pk_send_force_swap_with_fees_include_fee() {
+    setup_tracing();
+
+    // Create a mint with fee_ppk=1000 (1 sat per input proof)
+    let mint = create_mint_with_fee(1000)
+        .await
+        .expect("Failed to create test mint with fees");
+    let wallet_sender = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create sender wallet");
+    let wallet_receiver = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create receiver wallet");
+
+    // Fund sender with 64 sats
+    fund_wallet(wallet_sender.clone(), 64, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    // Generate P2PK spending conditions
+    let secret = SecretKey::generate();
+    let spending_conditions = SpendingConditions::new_p2pk(secret.public_key(), None);
+
+    let send_amount = Amount::from(10);
+
+    // Send with include_fee=true so token covers the redemption fee
+    let prepared = wallet_sender
+        .prepare_send(
+            send_amount,
+            SendOptions {
+                conditions: Some(spending_conditions),
+                include_fee: true,
+                ..Default::default()
+            },
+        )
+        .await
+        .expect("prepare_send should succeed with include_fee and force_swap");
+
+    let swap_fee = prepared.swap_fee();
+    let send_fee = prepared.send_fee();
+    assert!(
+        swap_fee > Amount::ZERO,
+        "Expected non-zero swap fee for force_swap with fee_ppk=1000"
+    );
+    assert!(
+        send_fee > Amount::ZERO,
+        "Expected non-zero send fee with include_fee=true and fee_ppk=1000"
+    );
+
+    let token = prepared
+        .confirm(None)
+        .await
+        .expect("confirm should succeed");
+
+    // Token should include amount + send_fee (so recipient can pay the redemption fee)
+    let keysets_info = wallet_sender.get_mint_keysets().await.unwrap();
+    let token_proofs = token.proofs(&keysets_info).unwrap();
+    assert_eq!(
+        send_amount + send_fee,
+        token_proofs.total_amount().unwrap(),
+        "Token should contain send amount + redemption fee"
+    );
+
+    // Receiver redeems the token using the P2PK signing key
+    let received_amount = wallet_receiver
+        .receive(
+            &token.to_string(),
+            ReceiveOptions {
+                p2pk_signing_keys: vec![secret],
+                ..Default::default()
+            },
+        )
+        .await
+        .expect("Receiver should be able to redeem P2PK token");
+
+    // Receiver should get exactly the send_amount after the redemption fee is deducted
+    assert_eq!(
+        send_amount, received_amount,
+        "Receiver should get exactly the requested amount after fees"
+    );
+
+    // Verify sender balance
+    let expected_sender_balance = Amount::from(64) - send_amount - swap_fee - send_fee;
+    assert_eq!(
+        expected_sender_balance,
+        wallet_sender.total_balance().await.unwrap(),
+        "Sender balance should be reduced by amount + swap_fee + send_fee"
+    );
+}
+
 async fn get_keyset_id(mint: &Mint) -> Id {
     let keys = mint.pubkeys().keysets.first().unwrap().clone();
     keys.verify_id()

+ 1 - 1
crates/cdk/src/wallet/send/saga/mod.rs

@@ -205,7 +205,7 @@ impl<'a> SendSaga<'a, Initial> {
             available_proofs,
             &active_keyset_ids,
             &keyset_fees,
-            opts.include_fee,
+            opts.include_fee || force_swap,
         )?;
         let selected_total = selected_proofs.total_amount()?;