From 557f80ae603babcb74e78bcf9de884cc8359f48d Mon Sep 17 00:00:00 2001 From: vnprc <9425366+vnprc@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:14:24 -0400 Subject: [PATCH 1/2] feat: add p2pk_signing_keys to SendOptions Add `p2pk_signing_keys: Vec` to `SendOptions` so wallets holding P2PK-locked proofs can spend them through the standard `prepare_send` / `confirm` flow without manually calling `swap()`. - Extract `sign_proofs()` into `wallet/util.rs` with four unit tests - Refactor `receive/saga/mod.rs` to call `sign_proofs()` (no behaviour change) - Sign `proofs_to_swap` in `send/saga/mod.rs::confirm()` before swap - Add `p2pk_signing_keys` to cdk-ffi `SendOptions` struct and both `From` conversions (was previously hardcoded to `Vec::new()`) - Fix CHANGELOG `## Added` heading level to `### Added` - Add integration test `test_p2pk_send_options_signing_keys` covering the full prepare_send / confirm flow with P2PK-locked input proofs --- CHANGELOG.md | 4 + crates/cdk-ffi/src/lib.rs | 1 + crates/cdk-ffi/src/types/wallet.rs | 5 + .../tests/integration_tests_pure.rs | 116 ++++++++++++ crates/cdk/src/wallet/receive/saga/mod.rs | 90 ++-------- crates/cdk/src/wallet/send/mod.rs | 7 +- crates/cdk/src/wallet/send/saga/mod.rs | 9 +- crates/cdk/src/wallet/util.rs | 167 ++++++++++++++++++ 8 files changed, 318 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 650b7e812..63de9c130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ ## [Unreleased] +### Added + +- cdk: Add `p2pk_signing_keys` to `SendOptions` so P2PK-locked proofs can be signed in `PreparedSend::confirm` ([vnprc]) + ## [0.15.1](https://github.com/cashubtc/cdk/releases/tag/v0.15.1) ## Fixed diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index 786f05aa8..b8331a131 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -195,6 +195,7 @@ mod tests { max_proofs: Some(10), metadata, use_p2bk: false, + p2pk_signing_keys: Vec::new(), }; assert!(options.memo.is_some()); diff --git a/crates/cdk-ffi/src/types/wallet.rs b/crates/cdk-ffi/src/types/wallet.rs index 8870ca12f..b57e8d8cb 100644 --- a/crates/cdk-ffi/src/types/wallet.rs +++ b/crates/cdk-ffi/src/types/wallet.rs @@ -117,6 +117,8 @@ pub struct SendOptions { pub max_proofs: Option, /// Metadata pub metadata: HashMap, + /// Signing keys for P2PK-locked input proofs + pub p2pk_signing_keys: Vec, } impl Default for SendOptions { @@ -130,6 +132,7 @@ impl Default for SendOptions { max_proofs: None, metadata: HashMap::new(), use_p2bk: false, + p2pk_signing_keys: Vec::new(), } } } @@ -145,6 +148,7 @@ impl From for cdk::wallet::SendOptions { max_proofs: opts.max_proofs.map(|p| p as usize), metadata: opts.metadata, use_p2bk: opts.use_p2bk, + p2pk_signing_keys: opts.p2pk_signing_keys.into_iter().map(Into::into).collect(), } } } @@ -160,6 +164,7 @@ impl From for SendOptions { max_proofs: opts.max_proofs.map(|p| p as u32), metadata: opts.metadata, use_p2bk: opts.use_p2bk, + p2pk_signing_keys: opts.p2pk_signing_keys.into_iter().map(Into::into).collect(), } } } diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index dc78bbce4..790a3e7b8 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -30,6 +30,7 @@ use cdk::wallet::types::{TransactionDirection, TransactionId}; use cdk::wallet::{ReceiveOptions, SendMemo, SendOptions}; use cdk::{Amount, StreamExt}; use cdk_common::mint::OperationKind; +use cdk_common::wallet::ProofInfo; use cdk_fake_wallet::create_fake_invoice; use cdk_integration_tests::init_pure_tests::*; use tokio::time::sleep; @@ -2112,3 +2113,118 @@ async fn test_p2bk_multi_key_receive() { assert_eq!(send_amount, received_amount); } + +/// Tests that `p2pk_signing_keys` in `SendOptions` enables spending P2PK-locked +/// input proofs through the standard `prepare_send` / `confirm` flow. +/// +/// Scenario: A wallet holds P2PK-locked proofs (locked to its own key). It uses +/// `p2pk_signing_keys` in `SendOptions` to sign those proofs before the swap, +/// allowing the mint to validate and accept the spend. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_p2pk_send_options_signing_keys() { + setup_tracing(); + + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet_alice = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create alice wallet"); + let wallet_bob = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create bob wallet"); + + // Fund alice with 64 sats (plain proofs) + fund_wallet(wallet_alice.clone(), 64, None) + .await + .expect("Failed to fund alice"); + + // Generate alice's P2PK key and spending conditions + let alice_secret = SecretKey::generate(); + let spending_conditions = SpendingConditions::new_p2pk(alice_secret.public_key(), None); + + // Get alice's plain proofs so we can swap them for P2PK-locked proofs + let plain_proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Failed to get alice's proofs"); + let plain_ys: Vec<_> = plain_proofs.iter().map(|p| p.y().unwrap()).collect(); + + let keyset_id = get_keyset_id(&mint).await; + let keys = mint.pubkeys().keysets.first().cloned().unwrap().keys; + let fee_and_amounts = (0u64, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + + // Swap plain proofs → P2PK-locked proofs at the mint + let pre_mint = PreMintSecrets::with_conditions( + keyset_id, + Amount::from(64), + &SplitTarget::default(), + &spending_conditions, + &fee_and_amounts, + ) + .unwrap(); + + let swap_request = SwapRequest::new(plain_proofs, pre_mint.blinded_messages()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + let p2pk_proofs = construct_proofs( + swap_response.signatures, + pre_mint.rs(), + pre_mint.secrets(), + &keys, + ) + .unwrap(); + + // Replace alice's plain proofs in the wallet DB with the P2PK-locked proofs + let p2pk_proof_infos: Vec<_> = p2pk_proofs + .iter() + .map(|p| { + ProofInfo::new( + p.clone(), + wallet_alice.mint_url.clone(), + State::Unspent, + CurrencyUnit::Sat, + ) + .unwrap() + }) + .collect(); + wallet_alice + .localstore + .update_proofs(p2pk_proof_infos, plain_ys) + .await + .unwrap(); + + assert_eq!( + Amount::from(64), + wallet_alice.total_balance().await.unwrap(), + "Alice should have 64 sats of P2PK-locked proofs" + ); + + // Alice sends 10 sats; p2pk_signing_keys signs the input proofs before the swap + let send_amount = Amount::from(10); + let prepared = wallet_alice + .prepare_send( + send_amount, + SendOptions { + p2pk_signing_keys: vec![alice_secret], + ..Default::default() + }, + ) + .await + .expect("prepare_send should succeed with P2PK-locked input proofs"); + + let token = prepared + .confirm(None) + .await + .expect("confirm should succeed — P2PK proofs signed before swap"); + + // Bob receives the resulting clean token + let received = wallet_bob + .receive(&token.to_string(), ReceiveOptions::default()) + .await + .expect("Bob should receive the token without signing keys"); + + assert_eq!( + send_amount, received, + "Bob should receive exactly the send amount" + ); +} diff --git a/crates/cdk/src/wallet/receive/saga/mod.rs b/crates/cdk/src/wallet/receive/saga/mod.rs index 908f6f9bc..9da26c9a9 100644 --- a/crates/cdk/src/wallet/receive/saga/mod.rs +++ b/crates/cdk/src/wallet/receive/saga/mod.rs @@ -49,7 +49,7 @@ use super::ReceiveOptions; use crate::dhke::construct_proofs; use crate::nuts::nut00::ProofsMethods; use crate::nuts::nut10::Kind; -use crate::nuts::{Conditions, Proofs, PublicKey, SecretKey, SigFlag, State}; +use crate::nuts::{Conditions, Proofs, SecretKey, SigFlag, State}; use crate::util::hex; use crate::wallet::saga::{ add_compensation, clear_compensations, execute_compensations, new_compensations, Compensations, @@ -112,8 +112,6 @@ impl<'a> ReceiveSaga<'a, Initial> { let mut proofs = proofs; let proofs_amount = proofs.total_amount()?; - let mut _sig_flag = SigFlag::SigInputs; - // Map hash of preimage to preimage let hashed_to_preimage: HashMap = opts .preimages @@ -124,13 +122,7 @@ impl<'a> ReceiveSaga<'a, Initial> { }) .collect::, _>>()?; - let p2pk_signing_keys: HashMap = opts - .p2pk_signing_keys - .iter() - .map(|s| (s.x_only_public_key(&SECP256K1).0, s)) - .collect(); - - // Process each proof: verify DLEQ, handle P2PK/HTLC + // Process each proof: verify DLEQ and inject HTLC preimages for proof in &mut proofs { // Verify that proof DLEQ is valid if proof.dleq.is_some() { @@ -144,76 +136,19 @@ impl<'a> ReceiveSaga<'a, Initial> { proof.secret.clone(), ) { - let conditions: Result = secret - .secret_data() - .tags() - .cloned() - .unwrap_or_default() - .try_into(); - if let Ok(conditions) = conditions { - let mut pubkeys = Vec::new(); - - match secret.kind() { - Kind::P2PK => { - let data_key = PublicKey::from_str(secret.secret_data().data())?; - pubkeys.push(data_key); - } - Kind::HTLC => { - // HTLC data is a hash, not a pubkey. - // Add the pre-image and skip slot 0 pubkey. - let hashed_preimage = secret.secret_data().data(); - let preimage = hashed_to_preimage - .get(hashed_preimage) - .ok_or(Error::PreimageNotProvided)?; - proof.add_preimage(preimage.to_string()); - - // For HTLC, there is no slot 0 pubkey. But slot index for the tags still starts at 1! - } - } - - if let Some(mut cond_pubkeys) = conditions.pubkeys { - pubkeys.append(&mut cond_pubkeys); - } - if let Some(mut refund_keys) = conditions.refund_keys { - pubkeys.append(&mut refund_keys); - } - - for (i, pubkey) in pubkeys.iter().enumerate() { - let slot = match secret.kind() { - Kind::P2PK => i as u8, - _ => (i + 1) as u8, // HTLC skips slot 0 since it's a hash, not a pubkey - }; - if let Some(ephemeral_key) = proof.p2pk_e { - for signing_key in p2pk_signing_keys.values() { - if let Ok(r) = - crate::nuts::nut28::ecdh_kdf(signing_key, &ephemeral_key, slot) - { - if let Ok(derived_key) = - crate::nuts::nut28::derive_signing_key_bip340( - signing_key, - &r, - pubkey, - ) - { - proof.sign_p2pk(derived_key)?; - break; - } - } - } - } else if let Some(signing) = - p2pk_signing_keys.get(&pubkey.x_only_public_key()) - { - proof.sign_p2pk(signing.to_owned().clone())?; - } - } - - if conditions.sig_flag.eq(&SigFlag::SigAll) { - _sig_flag = SigFlag::SigAll; - } + if secret.kind() == Kind::HTLC { + let hashed_preimage = secret.secret_data().data(); + let preimage = hashed_to_preimage + .get(hashed_preimage) + .ok_or(Error::PreimageNotProvided)?; + proof.add_preimage(preimage.to_string()); } } } + // Sign P2PK-locked proofs (and HTLC condition keys) + crate::wallet::util::sign_proofs(&mut proofs, &opts.p2pk_signing_keys)?; + Ok(ReceiveSaga { wallet: self.wallet, compensations: self.compensations, @@ -487,9 +422,6 @@ impl<'a> ReceiveSaga<'a, Finalized> { } } -// Required import for PublicKey::from_str -use std::str::FromStr; - impl std::fmt::Debug for ReceiveSaga<'_, S> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ReceiveSaga") diff --git a/crates/cdk/src/wallet/send/mod.rs b/crates/cdk/src/wallet/send/mod.rs index b570887f7..e31423a62 100644 --- a/crates/cdk/src/wallet/send/mod.rs +++ b/crates/cdk/src/wallet/send/mod.rs @@ -13,7 +13,7 @@ use super::SendKind; use crate::amount::SplitTarget; use crate::fees::calculate_fee; use crate::nuts::nut00::ProofsMethods; -use crate::nuts::{Proofs, SpendingConditions, Token}; +use crate::nuts::{Proofs, SecretKey, SpendingConditions, Token}; use crate::{Amount, Error, Wallet}; pub(crate) mod saga; @@ -380,6 +380,11 @@ pub struct SendOptions { /// /// When true, P2PK conditions will be converted to P2BK by blinding the public key pub use_p2bk: bool, + /// Signing keys for P2PK-locked input proofs. + /// + /// When the wallet holds proofs locked with `SpendingConditions::new_p2pk`, provide the + /// corresponding secret keys here so they are signed before the swap in [`PreparedSend::confirm`]. + pub p2pk_signing_keys: Vec, } /// Send memo diff --git a/crates/cdk/src/wallet/send/saga/mod.rs b/crates/cdk/src/wallet/send/saga/mod.rs index 3bba4802b..26b286276 100644 --- a/crates/cdk/src/wallet/send/saga/mod.rs +++ b/crates/cdk/src/wallet/send/saga/mod.rs @@ -427,7 +427,7 @@ impl<'a> SendSaga<'a, Prepared> { let operation_id = self.state_data.operation_id; let amount = self.state_data.amount; let options = self.state_data.options.clone(); - let proofs_to_swap = self.state_data.proofs_to_swap.clone(); + let mut proofs_to_swap = self.state_data.proofs_to_swap.clone(); let proofs_to_send = self.state_data.proofs_to_send.clone(); let swap_fee = self.state_data.swap_fee; let send_fee = self.state_data.send_fee; @@ -450,6 +450,13 @@ impl<'a> SendSaga<'a, Prepared> { tracing::debug!("Swapping proofs; swap_amount={:?}", swap_amount); + if !options.p2pk_signing_keys.is_empty() { + crate::wallet::util::sign_proofs( + &mut proofs_to_swap, + &options.p2pk_signing_keys, + )?; + } + let keyset_id = self.wallet.fetch_active_keyset().await?.id; // Capture counter start before swap diff --git a/crates/cdk/src/wallet/util.rs b/crates/cdk/src/wallet/util.rs index 39d6de82e..e46bdb2d4 100644 --- a/crates/cdk/src/wallet/util.rs +++ b/crates/cdk/src/wallet/util.rs @@ -1,5 +1,14 @@ //! Wallet Utility Functions +use std::collections::HashMap; +use std::str::FromStr; + +use bitcoin::XOnlyPublicKey; + +use crate::nuts::nut10::Kind; +use crate::nuts::{Conditions, Proofs, PublicKey, SecretKey}; +use crate::{Error, SECP256K1}; + /// Extract token from text pub fn token_from_text(text: &str) -> Option<&str> { let text = text.trim(); @@ -13,8 +22,96 @@ pub fn token_from_text(text: &str) -> Option<&str> { None } +/// Sign P2PK-locked proofs using the provided signing keys. +/// +/// For each proof with a recognized NUT-10 secret: +/// - P2PK: signs the data key (slot 0) and any condition keys (slots 1+) +/// - HTLC: signs condition keys (slots 1+) only; preimage injection is the caller's responsibility +/// +/// Proofs without a NUT-10 secret, or with no matching signing key, are left unchanged. +pub(crate) fn sign_proofs( + proofs: &mut Proofs, + p2pk_signing_keys: &[SecretKey], +) -> Result<(), Error> { + if p2pk_signing_keys.is_empty() { + return Ok(()); + } + + let key_map: HashMap = p2pk_signing_keys + .iter() + .map(|s| (s.x_only_public_key(&SECP256K1).0, s)) + .collect(); + + for proof in proofs.iter_mut() { + let Ok(secret) = >::try_into( + proof.secret.clone(), + ) else { + continue; + }; + + let conditions: Result = secret + .secret_data() + .tags() + .cloned() + .unwrap_or_default() + .try_into(); + + let Ok(conditions) = conditions else { + continue; + }; + + let mut pubkeys = Vec::new(); + + match secret.kind() { + Kind::P2PK => { + let data_key = PublicKey::from_str(secret.secret_data().data())?; + pubkeys.push(data_key); + } + Kind::HTLC => { + // HTLC slot 0 is a hash, not a pubkey. + // Condition keys (slots 1+) may still need signing. + // Preimage injection is handled separately by the caller. + } + } + + if let Some(mut cond_pubkeys) = conditions.pubkeys { + pubkeys.append(&mut cond_pubkeys); + } + if let Some(mut refund_keys) = conditions.refund_keys { + pubkeys.append(&mut refund_keys); + } + + for (i, pubkey) in pubkeys.iter().enumerate() { + let slot = match secret.kind() { + Kind::P2PK => i as u8, + Kind::HTLC => (i + 1) as u8, + }; + if let Some(ephemeral_key) = proof.p2pk_e { + for signing_key in key_map.values() { + if let Ok(r) = crate::nuts::nut28::ecdh_kdf(signing_key, &ephemeral_key, slot) { + if let Ok(derived_key) = + crate::nuts::nut28::derive_signing_key_bip340(signing_key, &r, pubkey) + { + proof.sign_p2pk(derived_key)?; + break; + } + } + } + } else if let Some(signing) = key_map.get(&pubkey.x_only_public_key()) { + proof.sign_p2pk((*signing).clone())?; + } + } + } + + Ok(()) +} + #[cfg(test)] mod tests { + use std::str::FromStr; + + use crate::nuts::{Id, Proof, SpendingConditions}; + use crate::Amount; use super::*; @@ -28,4 +125,74 @@ mod tests { assert_eq!(token, token_str) } + + fn make_p2pk_proof(pubkey: PublicKey) -> Proof { + let spending_conditions = SpendingConditions::new_p2pk(pubkey, None); + let nut10_secret: crate::nuts::nut10::Secret = spending_conditions.into(); + let secret: crate::secret::Secret = nut10_secret.try_into().unwrap(); + Proof::new( + Amount::from(1), + Id::from_str("00916bbf7ef91a36").unwrap(), + secret, + SecretKey::generate().public_key(), + ) + } + + fn make_plain_proof() -> Proof { + Proof::new( + Amount::from(1), + Id::from_str("00916bbf7ef91a36").unwrap(), + crate::secret::Secret::generate(), + SecretKey::generate().public_key(), + ) + } + + #[test] + fn sign_proofs_with_correct_key_adds_witness() { + let secret_key = SecretKey::generate(); + let pubkey = secret_key.public_key(); + + let mut proofs = vec![make_p2pk_proof(pubkey)]; + assert!(proofs[0].witness.is_none()); + + let keys = vec![secret_key]; + sign_proofs(&mut proofs, &keys).unwrap(); + + assert!(proofs[0].witness.is_some()); + } + + #[test] + fn sign_proofs_with_wrong_key_leaves_proof_unchanged() { + let pubkey = SecretKey::generate().public_key(); + let wrong_key = SecretKey::generate(); + + let mut proofs = vec![make_p2pk_proof(pubkey)]; + + let keys = vec![wrong_key]; + sign_proofs(&mut proofs, &keys).unwrap(); + + assert!(proofs[0].witness.is_none()); + } + + #[test] + fn sign_proofs_with_plain_proof_is_noop() { + let signing_key = SecretKey::generate(); + let mut proofs = vec![make_plain_proof()]; + + let keys = vec![signing_key]; + sign_proofs(&mut proofs, &keys).unwrap(); + + assert!(proofs[0].witness.is_none()); + } + + #[test] + fn sign_proofs_with_empty_keys_is_noop() { + let pubkey = SecretKey::generate().public_key(); + let mut proofs = vec![make_p2pk_proof(pubkey)]; + + let empty = Vec::::new(); + sign_proofs(&mut proofs, &empty).unwrap(); + + assert!(proofs[0].witness.is_none()); + } } From 5dd5b93a43c306b76eae0b872de345b2effb58a8 Mon Sep 17 00:00:00 2001 From: vnprc <9425366+vnprc@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:17:53 -0400 Subject: [PATCH 2/2] fix: force swap when p2pk_signing_keys are provided to clear spending conditions When a wallet holds P2PK-locked proofs whose total exactly equals the requested send amount, prepare_send short-circuits: all proofs go to proofs_to_send and proofs_to_swap is empty. Since confirm only signs proofs_to_swap, signing is skipped and the locked proofs flow out in the token unchanged, causing the recipient to get NUT11 errors. Fix by shadowing force_swap to true in internal_prepare when p2pk_signing_keys is non-empty, ensuring all proofs go through a real swap that signs and unlocks them before building the token. Add a regression test covering the exact-denomination case. --- .../tests/integration_tests_pure.rs | 121 ++++++++++++++++++ crates/cdk/src/wallet/send/saga/mod.rs | 4 + 2 files changed, 125 insertions(+) diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index 790a3e7b8..00257afef 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -2228,3 +2228,124 @@ async fn test_p2pk_send_options_signing_keys() { "Bob should receive exactly the send amount" ); } + +/// Regression test for the exact-denomination short-circuit bug in `p2pk_signing_keys`. +/// +/// When a wallet holds P2PK-locked proofs whose total exactly equals the requested +/// send amount, `prepare_send` takes a short-circuit path: all proofs go directly +/// to `proofs_to_send` (no swap needed), so `proofs_to_swap` is empty. +/// `confirm` only signs `proofs_to_swap`, so signing is skipped entirely and the +/// P2PK-locked proofs flow out in the token unchanged. +/// +/// Fix: when `p2pk_signing_keys` is non-empty, force any proofs in `proofs_to_send` +/// into `proofs_to_swap` so they are always signed and unlocked via a real swap. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_p2pk_signing_keys_exact_denomination_short_circuit() { + setup_tracing(); + + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet_alice = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create alice wallet"); + let wallet_bob = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create bob wallet"); + + // Fund alice with 8 sats (plain proofs) + fund_wallet(wallet_alice.clone(), 8, None) + .await + .expect("Failed to fund alice"); + + let alice_secret = SecretKey::generate(); + let spending_conditions = SpendingConditions::new_p2pk(alice_secret.public_key(), None); + + // Replace alice's plain proofs with P2PK-locked proofs for the same total amount + let plain_proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Failed to get alice's proofs"); + let plain_ys: Vec<_> = plain_proofs.iter().map(|p| p.y().unwrap()).collect(); + let total_amount = plain_proofs.total_amount().unwrap(); + + let keyset_id = get_keyset_id(&mint).await; + let keys = mint.pubkeys().keysets.first().cloned().unwrap().keys; + let fee_and_amounts = (0u64, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + + let pre_mint = PreMintSecrets::with_conditions( + keyset_id, + total_amount, + &SplitTarget::default(), + &spending_conditions, + &fee_and_amounts, + ) + .unwrap(); + + let swap_request = SwapRequest::new(plain_proofs, pre_mint.blinded_messages()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + let p2pk_proofs = construct_proofs( + swap_response.signatures, + pre_mint.rs(), + pre_mint.secrets(), + &keys, + ) + .unwrap(); + + let p2pk_proof_infos: Vec<_> = p2pk_proofs + .iter() + .map(|p| { + ProofInfo::new( + p.clone(), + wallet_alice.mint_url.clone(), + State::Unspent, + CurrencyUnit::Sat, + ) + .unwrap() + }) + .collect(); + wallet_alice + .localstore + .update_proofs(p2pk_proof_infos, plain_ys) + .await + .unwrap(); + + assert_eq!( + total_amount, + wallet_alice.total_balance().await.unwrap(), + "Alice should have P2PK-locked proofs totalling the full amount" + ); + + // Send the EXACT total — this triggers the exact-denomination short-circuit: + // proofs_to_swap is empty, so signing is skipped and locked proofs go directly + // into the token. + let prepared = wallet_alice + .prepare_send( + total_amount, + SendOptions { + p2pk_signing_keys: vec![alice_secret], + ..Default::default() + }, + ) + .await + .expect("prepare_send should succeed"); + + // Before the fix, proofs_to_swap is empty (exact denomination match short-circuits + // the swap), so the token will contain P2PK-locked proofs that Bob cannot receive. + let token = prepared + .confirm(None) + .await + .expect("confirm should succeed"); + + // Bob must be able to receive the token without any signing keys. + // Before the fix this fails because the token proofs are still P2PK-locked. + let received = wallet_bob + .receive(&token.to_string(), ReceiveOptions::default()) + .await + .expect("Bob should receive the unlocked token without signing keys"); + + assert_eq!( + total_amount, received, + "Bob should receive the full amount as unlocked proofs" + ); +} diff --git a/crates/cdk/src/wallet/send/saga/mod.rs b/crates/cdk/src/wallet/send/saga/mod.rs index 26b286276..1cb4ddedf 100644 --- a/crates/cdk/src/wallet/send/saga/mod.rs +++ b/crates/cdk/src/wallet/send/saga/mod.rs @@ -315,6 +315,10 @@ impl<'a> SendSaga<'a, Initial> { let is_exact_or_offline = exact_proofs || opts.send_kind.is_offline() || opts.send_kind.has_tolerance(); + // When p2pk_signing_keys are provided, all proofs must go through a swap so + // they are signed and the token contains fresh unconditioned proofs. + let force_swap = force_swap || !opts.p2pk_signing_keys.is_empty(); + let keyset_fees_and_amounts = self.wallet.get_keyset_fees_and_amounts().await?; let keyset_fees: HashMap = keyset_fees_and_amounts .iter()