From 063b74b2d38940fd5eb1f394e128bf9f8a0eee6a Mon Sep 17 00:00:00 2001 From: vnprc Date: Fri, 3 Apr 2026 17:37:16 -0400 Subject: [PATCH 1/4] feat: add P2PK signing key support to send --- crates/cdk-common/src/wallet/mod.rs | 4 + crates/cdk-ffi/src/lib.rs | 18 + crates/cdk-ffi/src/types/wallet.rs | 26 +- crates/cdk-ffi/src/wallet.rs | 2 +- .../tests/integration_tests_pure.rs | 1139 ++++++++++++++++- crates/cdk/src/wallet/send/saga/mod.rs | 193 ++- crates/cdk/src/wallet/util.rs | 225 ++++ 7 files changed, 1590 insertions(+), 17 deletions(-) diff --git a/crates/cdk-common/src/wallet/mod.rs b/crates/cdk-common/src/wallet/mod.rs index 936d9571de..cb7f3c6ccc 100644 --- a/crates/cdk-common/src/wallet/mod.rs +++ b/crates/cdk-common/src/wallet/mod.rs @@ -335,6 +335,10 @@ pub struct SendOptions { pub metadata: HashMap, /// Use P2BK (NUT-28) pub use_p2bk: bool, + /// Signing keys for P2PK-locked input proofs; auto-detected from the wallet keyring if omitted + pub p2pk_signing_keys: Vec, + /// If `true`, sign and forward P2PK-locked proofs directly without swapping them for fresh ones + pub allow_locked_proofs: bool, } /// Send memo diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index 04e6bedc82..36d4e427ef 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -80,6 +80,8 @@ mod tests { assert!(!options.include_fee); assert!(options.max_proofs.is_none()); assert!(options.metadata.is_empty()); + assert!(options.p2pk_signing_keys.is_empty()); + assert!(!options.allow_locked_proofs); } #[test] @@ -201,6 +203,8 @@ mod tests { max_proofs: Some(10), metadata, use_p2bk: false, + p2pk_signing_keys: Vec::new(), + allow_locked_proofs: false, }; assert!(options.memo.is_some()); @@ -260,6 +264,20 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_send_options_invalid_secret_key_returns_error() { + let options = SendOptions { + p2pk_signing_keys: vec![SecretKey { + hex: "z".repeat(64), + }], + ..Default::default() + }; + + let result: Result = options.try_into(); + + assert!(result.is_err()); + } + #[test] fn test_proof_with_invalid_dleq_returns_error() { let proof = Proof { diff --git a/crates/cdk-ffi/src/types/wallet.rs b/crates/cdk-ffi/src/types/wallet.rs index a8c8d79edb..f32801f022 100644 --- a/crates/cdk-ffi/src/types/wallet.rs +++ b/crates/cdk-ffi/src/types/wallet.rs @@ -160,6 +160,10 @@ pub struct SendOptions { pub max_proofs: Option, /// Metadata pub metadata: HashMap, + /// Signing keys for P2PK-locked input proofs + pub p2pk_signing_keys: Vec, + /// Allow P2PK-locked proofs to be included directly in the token without a swap + pub allow_locked_proofs: bool, } impl Default for SendOptions { @@ -173,13 +177,23 @@ impl Default for SendOptions { max_proofs: None, metadata: HashMap::new(), use_p2bk: false, + p2pk_signing_keys: Vec::new(), + allow_locked_proofs: false, } } } -impl From for cdk::wallet::SendOptions { - fn from(opts: SendOptions) -> Self { - cdk::wallet::SendOptions { +impl TryFrom for cdk::wallet::SendOptions { + type Error = FfiError; + + fn try_from(opts: SendOptions) -> Result { + let p2pk_signing_keys = opts + .p2pk_signing_keys + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?; + + Ok(cdk::wallet::SendOptions { memo: opts.memo.map(Into::into), conditions: opts.conditions.and_then(|c| c.try_into().ok()), amount_split_target: opts.amount_split_target.into(), @@ -188,7 +202,9 @@ 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, + allow_locked_proofs: opts.allow_locked_proofs, + }) } } @@ -203,6 +219,8 @@ 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(), + allow_locked_proofs: opts.allow_locked_proofs, } } } diff --git a/crates/cdk-ffi/src/wallet.rs b/crates/cdk-ffi/src/wallet.rs index 300bf4b16e..05bd0768c3 100644 --- a/crates/cdk-ffi/src/wallet.rs +++ b/crates/cdk-ffi/src/wallet.rs @@ -210,7 +210,7 @@ impl Wallet { ) -> Result, FfiError> { let prepared = self .inner - .prepare_send(amount.into(), options.into()) + .prepare_send(amount.into(), options.try_into()?) .await?; Ok(std::sync::Arc::new(PreparedSend::new( self.inner.clone(), diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index d2199eed39..c95e4a55cd 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -21,6 +21,8 @@ use bip39::Mnemonic; use cashu::amount::SplitTarget; use cashu::dhke::construct_proofs; use cashu::mint_url::MintUrl; +use cashu::nuts::nut10::Conditions; +use cashu::nuts::SigFlag; use cashu::{ CurrencyUnit, Id, MeltRequest, NotificationPayload, PaymentMethod, PreMintSecrets, ProofState, SecretKey, SpendingConditions, State, SwapRequest, @@ -36,6 +38,7 @@ use cdk_common::mint::OperationKind; use cdk_common::payment::{ MintPayment, OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, }; +use cdk_common::wallet::ProofInfo; use cdk_common::{MeltQuoteCreateResponse, MeltQuoteRequest, MeltQuoteResponse}; use cdk_fake_wallet::create_fake_invoice; use cdk_integration_tests::init_pure_tests::*; @@ -2431,7 +2434,6 @@ async fn test_custom_melt_quote_status_preserves_extra_json() { })) ); } - #[tokio::test] async fn test_custom_melt_quote_id_propagates_to_payment_processor() { setup_tracing(); @@ -2488,3 +2490,1138 @@ async fn test_custom_melt_quote_id_propagates_to_payment_processor() { "the quote_id passed to get_payment_quote must equal the quote_id surfaced to the wallet", ); } + +/// Test that a wallet holding P2PK-locked proofs can spend them via `prepare_send` / `confirm` +/// by providing `p2pk_signing_keys` in `SendOptions`. +/// +/// The wallet signs the input proofs before the swap, so the mint accepts the spend and +/// returns fresh, unlocked proofs. Bob receives a clean token with no spending conditions. +#[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 without needing any signing keys + 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" + ); +} + +/// Regression test for the exact-denomination short-circuit in `p2pk_signing_keys`. +/// +/// When a wallet holds P2PK-locked proofs whose total exactly equals the requested send amount, +/// `prepare_send` would normally take a short-circuit path: all proofs go directly to +/// `proofs_to_send` (no swap), so signing is skipped and locked proofs end up in the token. +/// +/// Fix: when `p2pk_signing_keys` is non-empty and `allow_locked_proofs` is false (the default), +/// force a swap so the token always contains fresh, unconditioned proofs. +#[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 — without the force-swap fix this triggers the short-circuit: + // proofs_to_swap is empty, signing is skipped, and locked proofs flow 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"); + + let token = prepared + .confirm(None) + .await + .expect("confirm should succeed"); + + // Bob must receive the token without any signing keys, proving the proofs were unlocked. + 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" + ); +} + +/// Test that `allow_locked_proofs: true` opts in to passing signed P2PK-locked proofs +/// directly in the token without a swap. +/// +/// Bob must provide the corresponding signing key when receiving, since the proofs still +/// carry their spending conditions. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_p2pk_allow_locked_proofs_passthrough() { + 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"); + + // Lock the proofs to a shared key that both alice (sender) and bob (receiver) hold + let shared_secret = SecretKey::generate(); + let spending_conditions = SpendingConditions::new_p2pk(shared_secret.public_key(), None); + + 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(); + + // Alice sends with allow_locked_proofs: proofs are signed but not swapped + let prepared = wallet_alice + .prepare_send( + total_amount, + SendOptions { + p2pk_signing_keys: vec![shared_secret.clone()], + allow_locked_proofs: true, + ..Default::default() + }, + ) + .await + .expect("prepare_send should succeed"); + + let token = prepared + .confirm(None) + .await + .expect("confirm should succeed with allow_locked_proofs"); + + // Alice pre-signed the proofs before sending, so the witness is already attached and the + // proof is effectively bearer. Bob does NOT need to provide the signing key — the mint + // will verify Alice's existing witness signature and accept the swap. + let received = wallet_bob + .receive(&token.to_string(), ReceiveOptions::default()) + .await + .expect("Bob should receive the token using the shared signing key"); + + assert_eq!(total_amount, received, "Bob should receive the full amount"); +} + +/// Test that `allow_locked_proofs` is rejected when any passthrough proof carries `SIG_ALL`. +/// +/// A `SIG_ALL` signature must commit to the swap outputs, which do not exist at signing time. +/// The recipient cannot construct valid outputs for such a proof, so any redemption attempt +/// would be rejected by the mint. `confirm` should fail fast rather than silently producing +/// an unspendable token. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_p2pk_allow_locked_proofs_rejects_sig_all() { + 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"); + + fund_wallet(wallet_alice.clone(), 8, None) + .await + .expect("Failed to fund alice"); + + let alice_secret = SecretKey::generate(); + let sig_all_conditions = Conditions { + sig_flag: SigFlag::SigAll, + ..Default::default() + }; + let spending_conditions = + SpendingConditions::new_p2pk(alice_secret.public_key(), Some(sig_all_conditions)); + + 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(); + + // prepare_send succeeds: allow_locked_proofs skips the force-swap path + let prepared = wallet_alice + .prepare_send( + total_amount, + SendOptions { + p2pk_signing_keys: vec![alice_secret], + allow_locked_proofs: true, + ..Default::default() + }, + ) + .await + .expect("prepare_send should succeed"); + + // confirm must fail: SIG_ALL proofs cannot be pre-signed for passthrough + let result = prepared.confirm(None).await; + assert!( + result.is_err(), + "confirm should fail for SIG_ALL proofs in passthrough mode" + ); +} + +/// The wallet's keyring automatically supplies signing keys at send time. +/// +/// Alice generates a P2PK key via `generate_public_key()`, which stores the key in the wallet +/// keyring. She then receives P2PK-locked proofs for that key. When she sends without providing +/// any `p2pk_signing_keys` in `SendOptions`, the send saga must auto-detect the key from the +/// keyring, sign the proofs before the swap, and produce a clean, unlocked token that Bob can +/// receive without any signing keys. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_p2pk_send_keyring_auto_detection() { + 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"); + + // Alice generates a P2PK key and stores it in her keyring. + let alice_pubkey = wallet_alice + .generate_public_key() + .await + .expect("generate_public_key should succeed"); + let spending_conditions = SpendingConditions::new_p2pk(alice_pubkey, None); + + // Fund alice with plain proofs, then swap them for P2PK-locked proofs. + fund_wallet(wallet_alice.clone(), 64, None) + .await + .expect("Failed to fund alice"); + + 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(); + + 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(); + + 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 without providing p2pk_signing_keys — keyring auto-detects the key. + let send_amount = Amount::from(10); + let prepared = wallet_alice + .prepare_send(send_amount, SendOptions::default()) + .await + .expect("prepare_send should succeed with keyring keys"); + + let token = prepared + .confirm(None) + .await + .expect("confirm should sign proofs from keyring and produce a clean token"); + + // Bob receives the clean token without needing any signing keys. + 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" + ); +} + +/// When the wallet holds a mix of P2PK-locked proofs (unknown key) and bearer proofs, +/// `prepare_send` should exclude the locked proofs from selection and succeed using the +/// bearer proofs alone. The resulting token contains no spending conditions. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_p2pk_unsignable_proof_falls_back_to_bearer() { + 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 of plain bearer proofs. + fund_wallet(wallet_alice.clone(), 64, None) + .await + .expect("Failed to fund alice"); + + // Lock 8 sats to a random key that alice does NOT have in her keyring. + let unknown_key = SecretKey::generate(); + let spending_conditions = SpendingConditions::new_p2pk(unknown_key.public_key(), None); + + let plain_proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Failed to get alice's proofs"); + let proof_to_lock = plain_proofs + .iter() + .find(|p| p.amount == cashu::Amount::from(8)) + .cloned() + .expect("Alice should have an 8-sat proof"); + let proof_to_lock_y = proof_to_lock.y().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, + cashu::Amount::from(8), + &SplitTarget::default(), + &spending_conditions, + &fee_and_amounts, + ) + .unwrap(); + + let swap_request = SwapRequest::new(vec![proof_to_lock], pre_mint.blinded_messages()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + let locked_proofs = construct_proofs( + swap_response.signatures, + pre_mint.rs(), + pre_mint.secrets(), + &keys, + ) + .unwrap(); + + let locked_proof_infos: Vec<_> = locked_proofs + .iter() + .map(|p| { + ProofInfo::new( + p.clone(), + wallet_alice.mint_url.clone(), + State::Unspent, + CurrencyUnit::Sat, + ) + .unwrap() + }) + .collect(); + wallet_alice + .localstore + .update_proofs(locked_proof_infos, vec![proof_to_lock_y]) + .await + .unwrap(); + + // Alice sends 4 sats — a small amount well within her bearer balance. + // The selection algorithm must skip the locked 8-sat proof and use bearer proofs only. + let send_amount = cashu::Amount::from(4); + let token = wallet_alice + .prepare_send(send_amount, SendOptions::default()) + .await + .expect("prepare_send should succeed using only bearer proofs") + .confirm(None) + .await + .expect("confirm should succeed"); + + let received = wallet_bob + .receive(&token.to_string(), ReceiveOptions::default()) + .await + .expect("Bob should receive the clean token"); + + assert_eq!(send_amount, received); +} + +/// Malformed P2PK proofs should be excluded from selection just like well-formed proofs +/// locked to unknown keys. A bad locked proof must not block spending valid bearer proofs. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_malformed_p2pk_proof_falls_back_to_bearer() { + 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_wallet(wallet_alice.clone(), 64, None) + .await + .expect("Failed to fund alice"); + + let unknown_key = SecretKey::generate(); + let spending_conditions = SpendingConditions::new_p2pk(unknown_key.public_key(), None); + + let plain_proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Failed to get alice's proofs"); + let proof_to_lock = plain_proofs + .iter() + .find(|p| p.amount == cashu::Amount::from(8)) + .cloned() + .expect("Alice should have an 8-sat proof"); + let proof_to_lock_y = proof_to_lock.y().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, + cashu::Amount::from(8), + &SplitTarget::default(), + &spending_conditions, + &fee_and_amounts, + ) + .unwrap(); + + let swap_request = SwapRequest::new(vec![proof_to_lock], pre_mint.blinded_messages()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + let mut locked_proofs = construct_proofs( + swap_response.signatures, + pre_mint.rs(), + pre_mint.secrets(), + &keys, + ) + .unwrap(); + locked_proofs[0].secret = + cdk::secret::Secret::new(r#"["P2PK",{"nonce":"bad","data":"not-a-public-key"}]"#); + + let locked_proof_infos: Vec<_> = locked_proofs + .iter() + .map(|p| { + ProofInfo::new( + p.clone(), + wallet_alice.mint_url.clone(), + State::Unspent, + CurrencyUnit::Sat, + ) + .unwrap() + }) + .collect(); + wallet_alice + .localstore + .update_proofs(locked_proof_infos, vec![proof_to_lock_y]) + .await + .unwrap(); + + let send_amount = cashu::Amount::from(4); + let token = wallet_alice + .prepare_send(send_amount, SendOptions::default()) + .await + .expect("prepare_send should succeed using only bearer proofs") + .confirm(None) + .await + .expect("confirm should succeed"); + + let received = wallet_bob + .receive(&token.to_string(), ReceiveOptions::default()) + .await + .expect("Bob should receive the clean token"); + + assert_eq!(send_amount, received); +} + +/// When the wallet holds ONLY P2PK-locked proofs for which it has no signing key, +/// `prepare_send` must return `InsufficientFunds` rather than propagating a confusing +/// mint-rejection error from confirm. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_p2pk_unsignable_proof_only_gives_insufficient_funds() { + 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"); + + // Fund alice with 64 sats, then swap all of them for proofs locked to an unknown key. + fund_wallet(wallet_alice.clone(), 64, None) + .await + .expect("Failed to fund alice"); + + let unknown_key = SecretKey::generate(); + let spending_conditions = SpendingConditions::new_p2pk(unknown_key.public_key(), None); + + 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(); + + 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 locked_proofs = construct_proofs( + swap_response.signatures, + pre_mint.rs(), + pre_mint.secrets(), + &keys, + ) + .unwrap(); + + let locked_proof_infos: Vec<_> = locked_proofs + .iter() + .map(|p| { + ProofInfo::new( + p.clone(), + wallet_alice.mint_url.clone(), + State::Unspent, + CurrencyUnit::Sat, + ) + .unwrap() + }) + .collect(); + wallet_alice + .localstore + .update_proofs(locked_proof_infos, plain_ys) + .await + .unwrap(); + + // All proofs are locked to an unknown key. prepare_send must fail with InsufficientFunds. + let err = wallet_alice + .prepare_send(Amount::from(10), SendOptions::default()) + .await + .expect_err("prepare_send should fail when no signable proofs exist"); + + assert!( + matches!(err, cdk::Error::InsufficientFunds), + "expected InsufficientFunds, got: {err:?}" + ); +} + +/// A proof is only selectable if all required P2PK signatures can be produced. +/// +/// This catches multisig P2PK conditions where the wallet has the data key but not enough +/// additional `pubkeys` to satisfy `n_sigs`. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_p2pk_partially_signable_proof_only_gives_insufficient_funds() { + 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 alice_pubkey = wallet_alice + .generate_public_key() + .await + .expect("generate_public_key should succeed"); + let unknown_key = SecretKey::generate(); + let conditions = Conditions::new( + None, + Some(vec![unknown_key.public_key()]), + None, + Some(2), + None, + None, + ) + .unwrap(); + let spending_conditions = SpendingConditions::new_p2pk(alice_pubkey, Some(conditions)); + + fund_wallet(wallet_alice.clone(), 8, None) + .await + .expect("Failed to fund alice"); + + 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 locked_proofs = construct_proofs( + swap_response.signatures, + pre_mint.rs(), + pre_mint.secrets(), + &keys, + ) + .unwrap(); + + let locked_proof_infos: Vec<_> = locked_proofs + .iter() + .map(|p| { + ProofInfo::new( + p.clone(), + wallet_alice.mint_url.clone(), + State::Unspent, + CurrencyUnit::Sat, + ) + .unwrap() + }) + .collect(); + wallet_alice + .localstore + .update_proofs(locked_proof_infos, plain_ys) + .await + .unwrap(); + + let err = wallet_alice + .prepare_send(total_amount, SendOptions::default()) + .await + .expect_err("prepare_send should fail when P2PK requirements cannot be satisfied"); + + assert!( + matches!(err, cdk::Error::InsufficientFunds), + "expected InsufficientFunds, got: {err:?}" + ); +} + +/// Passthrough mode must not create a token containing partially signed locked proofs. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_p2pk_allow_locked_proofs_rejects_partial_signatures() { + 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"); + + fund_wallet(wallet_alice.clone(), 8, None) + .await + .expect("Failed to fund alice"); + + let alice_secret = SecretKey::generate(); + let unknown_key = SecretKey::generate(); + let conditions = Conditions::new( + None, + Some(vec![unknown_key.public_key()]), + None, + Some(2), + None, + None, + ) + .unwrap(); + let spending_conditions = + SpendingConditions::new_p2pk(alice_secret.public_key(), Some(conditions)); + + 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 locked_proofs = construct_proofs( + swap_response.signatures, + pre_mint.rs(), + pre_mint.secrets(), + &keys, + ) + .unwrap(); + + let locked_proof_infos: Vec<_> = locked_proofs + .iter() + .map(|p| { + ProofInfo::new( + p.clone(), + wallet_alice.mint_url.clone(), + State::Unspent, + CurrencyUnit::Sat, + ) + .unwrap() + }) + .collect(); + wallet_alice + .localstore + .update_proofs(locked_proof_infos, plain_ys) + .await + .unwrap(); + + let prepared = wallet_alice + .prepare_send( + total_amount, + SendOptions { + p2pk_signing_keys: vec![alice_secret], + allow_locked_proofs: true, + ..Default::default() + }, + ) + .await + .expect("prepare_send should succeed in passthrough mode"); + + let result = prepared.confirm(None).await; + assert!( + matches!( + result, + Err(cdk::Error::NUT11( + cdk::nuts::nut11::Error::SpendConditionsNotMet + )) + ), + "expected SpendConditionsNotMet, got: {result:?}" + ); +} + +/// Test that when a wallet holds a mix of P2PK-locked and plain unlocked proofs, sending with +/// `p2pk_signing_keys` only routes the locked proofs through the swap. +/// +/// The plain proofs are already bearer and go directly to `proofs_to_send` without a swap. +/// Bob receives a clean token with no spending conditions and needs no signing key to redeem it. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_p2pk_signing_keys_mixed_locked_and_unlocked_proofs() { + 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 of plain (unlocked) proofs. + fund_wallet(wallet_alice.clone(), 64, None) + .await + .expect("Failed to fund alice"); + + let alice_secret = SecretKey::generate(); + let spending_conditions = SpendingConditions::new_p2pk(alice_secret.public_key(), None); + + // Swap 8 sats of alice's plain proofs for P2PK-locked proofs at the mint, + // then replace them in her wallet. After this alice holds: + // - ~56 sats of plain unlocked proofs + // - 8 sats of P2PK-locked proofs + let plain_proofs = wallet_alice + .get_unspent_proofs() + .await + .expect("Failed to get alice's proofs"); + + 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(); + + // Select only the 8-sat proof to lock. + let proof_to_lock = plain_proofs + .iter() + .find(|p| p.amount == cashu::Amount::from(8)) + .cloned() + .expect("Alice should have an 8-sat proof"); + let proof_to_lock_y = proof_to_lock.y().unwrap(); + + let pre_mint = PreMintSecrets::with_conditions( + keyset_id, + cashu::Amount::from(8), + &SplitTarget::default(), + &spending_conditions, + &fee_and_amounts, + ) + .unwrap(); + + let swap_request = SwapRequest::new(vec![proof_to_lock], pre_mint.blinded_messages()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + let locked_proofs = construct_proofs( + swap_response.signatures, + pre_mint.rs(), + pre_mint.secrets(), + &keys, + ) + .unwrap(); + + // Replace the plain 8-sat proof with the locked ones in alice's wallet. + let locked_proof_infos: Vec<_> = locked_proofs + .iter() + .map(|p| { + ProofInfo::new( + p.clone(), + wallet_alice.mint_url.clone(), + State::Unspent, + CurrencyUnit::Sat, + ) + .unwrap() + }) + .collect(); + wallet_alice + .localstore + .update_proofs(locked_proof_infos, vec![proof_to_lock_y]) + .await + .unwrap(); + + assert_eq!( + cashu::Amount::from(64), + wallet_alice.total_balance().await.unwrap(), + "Alice should still have 64 sats total (56 unlocked + 8 locked)" + ); + + // Alice sends 10 sats. The selection algorithm will pick a mix of proofs that + // includes the locked 8-sat proof. With the partition fix, only the locked proofs + // are routed through the swap; the unlocked ones bypass it. + let send_amount = cashu::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 a mixed wallet"); + + let token = prepared + .confirm(None) + .await + .expect("confirm should succeed"); + + // Bob must be able to receive the token without any signing keys, proving that + // the locked proofs were swapped to fresh, unconditioned ones. + 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/send/saga/mod.rs b/crates/cdk/src/wallet/send/saga/mod.rs index bd0aba2ff2..b523bee599 100644 --- a/crates/cdk/src/wallet/send/saga/mod.rs +++ b/crates/cdk/src/wallet/send/saga/mod.rs @@ -61,8 +61,9 @@ //! | `[compensated]` | Send cancelled before token created, reserved proofs released | //! | `[skipped]` | Recovery deferred (mint unreachable), will retry on next recovery | -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use bitcoin::XOnlyPublicKey; use cdk_common::nut02::KeySetInfosMethods; use cdk_common::util::unix_time; use cdk_common::wallet::{ @@ -75,7 +76,9 @@ use tracing::instrument; use self::state::{Initial, Prepared, TokenCreated}; use super::{split_proofs_for_send, SendMemo, SendOptions}; use crate::amount::SplitTarget; +use crate::fees::calculate_fee; use crate::nuts::nut00::ProofsMethods; +use crate::nuts::nut11::{enforce_sig_flag, SigFlag}; use crate::nuts::{Proofs, State, Token}; use crate::wallet::keysets::KeysetFilter; use crate::wallet::saga::{ @@ -88,6 +91,83 @@ use crate::{Amount, Error, Wallet}; pub(crate) mod resume; pub(crate) mod state; +fn verify_p2pk_proofs(proofs: &crate::nuts::Proofs) -> Result<(), Error> { + for proof in proofs { + if crate::wallet::util::is_p2pk_locked(proof) { + proof.verify_p2pk()?; + } + } + + Ok(()) +} + +/// Filter a proof pool to retain only proofs that the wallet can sign and verify. +/// +/// P2PK-locked proofs with no matching key (neither in `explicit_keys` nor the wallet keyring) +/// are removed. All other proofs (plain, HTLC) pass through unchanged. +async fn filter_signable_proofs( + wallet: &Wallet, + proofs: crate::nuts::Proofs, + explicit_keys: &[crate::nuts::SecretKey], +) -> Result { + let mut out = Vec::with_capacity(proofs.len()); + + for proof in proofs { + // Fast path: non-P2PK proofs are always included. + if !crate::wallet::util::is_p2pk_locked(&proof) { + out.push(proof); + continue; + } + + let mut signed = vec![proof.clone()]; + let keys = match merge_keyring_keys(wallet, &signed, explicit_keys).await { + Ok(keys) => keys, + Err(Error::NUT01(_)) => continue, + Err(err) => return Err(err), + }; + match crate::wallet::util::sign_proofs(&mut signed, &keys) { + Ok(()) => {} + Err(Error::NUT01(_)) => continue, + Err(err) => return Err(err), + } + + if verify_p2pk_proofs(&signed).is_ok() { + out.push(proof); + } + } + + Ok(out) +} + +/// Build the signing key list for the given proofs by merging explicitly-provided keys with +/// any matching keys found in the wallet keyring. +/// +/// Explicit keys (from `SendOptions.p2pk_signing_keys`) take precedence; the keyring is only +/// consulted for pubkeys not already covered by the explicit set. +async fn merge_keyring_keys( + wallet: &Wallet, + proofs: &crate::nuts::Proofs, + explicit_keys: &[crate::nuts::SecretKey], +) -> Result, Error> { + let mut keys = explicit_keys.to_vec(); + let covered: HashSet = keys + .iter() + .map(|k| k.x_only_public_key(&crate::SECP256K1).0) + .collect(); + + let pubkeys = crate::wallet::util::collect_p2pk_pubkeys(proofs)?; + for pubkey in pubkeys { + let x_only = pubkey.x_only_public_key(); + if !covered.contains(&x_only) { + if let Some(secret_key) = wallet.get_signing_key(&pubkey).await? { + keys.push(secret_key); + } + } + } + + Ok(keys) +} + /// Saga pattern implementation for send operations. /// /// Uses the typestate pattern to enforce valid state transitions at compile-time. @@ -148,6 +228,17 @@ impl<'a> SendSaga<'a, Initial> { ) .await?; + // When passthrough is not opted in, exclude P2PK-locked proofs for which the wallet + // holds no signing key (neither explicit nor in the keyring). Without a key, such proofs + // cannot be signed before the swap and would cause a mint rejection at confirm time. + // Excluding them here lets the selection algorithm work with only spendable proofs and + // surfaces a clean InsufficientFunds error if nothing else is available. + if !opts.allow_locked_proofs { + available_proofs = + filter_signable_proofs(self.wallet, available_proofs, &opts.p2pk_signing_keys) + .await?; + } + let mut force_swap = false; let available_sum = available_proofs.total_amount()?; if available_sum < amount { @@ -169,6 +260,15 @@ impl<'a> SendSaga<'a, Initial> { .into_iter() .map(|p| p.proof) .collect(); + + if !opts.allow_locked_proofs { + available_proofs = filter_signable_proofs( + self.wallet, + available_proofs, + &opts.p2pk_signing_keys, + ) + .await?; + } } } @@ -322,15 +422,51 @@ impl<'a> SendSaga<'a, Initial> { .map(|(key, values)| (*key, values.fee())) .collect(); - let split_result = split_proofs_for_send( - proofs, - &send_amounts, - amount, - send_fee.total, - &keyset_fees, - force_swap, - is_exact_or_offline, - )?; + // When the wallet holds P2PK-locked proofs and passthrough is not opted in, route them + // through a swap so the token contains fresh, unlocked proofs. The signing key may come + // from `SendOptions.p2pk_signing_keys` or be discovered automatically from the wallet + // keyring at confirm time; we partition eagerly regardless so that the swap is set up + // correctly even when only keyring keys will be used. + // + // Unlocked proofs bypass the swap entirely — they are already bearer. + // + // HTLC-locked proofs are intentionally excluded from this forced-swap path even though + // they also carry a NUT-10 secret. Spending an HTLC requires a preimage that signing + // keys alone cannot provide; routing HTLC proofs to a swap here would cause a mint + // rejection. HTLC support in the send path (including a `htlc_preimages` field on + // `SendOptions` and its own partition logic) is left for a follow-up PR. + let has_p2pk_locked = proofs.iter().any(crate::wallet::util::is_p2pk_locked); + let split_result = if has_p2pk_locked && !opts.allow_locked_proofs { + let (p2pk_locked, rest): (Vec<_>, Vec<_>) = proofs + .into_iter() + .partition(crate::wallet::util::is_p2pk_locked); + + let mut split = split_proofs_for_send( + rest, + &send_amounts, + amount, + send_fee.total, + &keyset_fees, + force_swap, + is_exact_or_offline, + )?; + + // Append the locked proofs to the swap set and recalculate the swap fee. + split.proofs_to_swap.extend(p2pk_locked); + split.swap_fee = + calculate_fee(&split.proofs_to_swap.count_by_keyset(), &keyset_fees)?.total; + split + } else { + split_proofs_for_send( + proofs, + &send_amounts, + amount, + send_fee.total, + &keyset_fees, + force_swap, + is_exact_or_offline, + )? + }; Ok(SendSaga { wallet: self.wallet, @@ -428,7 +564,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; @@ -444,6 +580,34 @@ impl<'a> SendSaga<'a, Prepared> { let mut counter_start = None; let mut counter_end = None; + // When locked-proof passthrough is opted in, sign proofs that bypass the swap + // before including them in the token. Signing is not optional: an unsigned + // P2PK-locked proof in a token is unspendable by the recipient because they + // do not hold the private key. Once signed, the proof becomes bearer — the + // mint will accept it from anyone who presents it. + // + // SIG_ALL is incompatible with passthrough: the signature would need to commit + // to the swap outputs, which do not exist at signing time. The recipient cannot + // create valid outputs for a proof signed with SIG_ALL, so any attempt to redeem + // it at the mint would fail. Reject early with a clear error rather than silently + // producing an unspendable token. + if options.allow_locked_proofs { + let sig_flag = enforce_sig_flag(final_proofs_to_send.clone()).sig_flag; + if sig_flag == SigFlag::SigAll { + return Err(crate::nuts::nut11::Error::SigAllNotSupportedHere.into()); + } + let keys = merge_keyring_keys( + self.wallet, + &final_proofs_to_send, + &options.p2pk_signing_keys, + ) + .await?; + if !keys.is_empty() { + crate::wallet::util::sign_proofs(&mut final_proofs_to_send, &keys)?; + } + verify_p2pk_proofs(&final_proofs_to_send)?; + } + if !proofs_to_swap.is_empty() { let swap_amount = total_send_amount .checked_sub(final_proofs_to_send.total_amount()?) @@ -451,6 +615,13 @@ impl<'a> SendSaga<'a, Prepared> { tracing::debug!("Swapping proofs; swap_amount={:?}", swap_amount); + let keys = + merge_keyring_keys(self.wallet, &proofs_to_swap, &options.p2pk_signing_keys) + .await?; + if !keys.is_empty() { + crate::wallet::util::sign_proofs(&mut proofs_to_swap, &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 39d6de82ed..b45169f8c8 100644 --- a/crates/cdk/src/wallet/util.rs +++ b/crates/cdk/src/wallet/util.rs @@ -1,5 +1,161 @@ //! Wallet Utility Functions +use std::collections::HashMap; +use std::str::FromStr; + +use bitcoin::XOnlyPublicKey; + +use crate::nuts::nut10::Kind; +use crate::nuts::{Conditions, Proof, Proofs, PublicKey, SecretKey}; +use crate::{Error, SECP256K1}; + +/// Returns `true` if the proof has a P2PK (NUT-11) spending condition. +/// +/// Checks `Kind::P2PK` specifically. HTLC proofs also carry a NUT-10 secret but require a +/// preimage to spend; they are intentionally excluded here because routing them through a swap +/// via signing keys alone would fail at the mint. +pub(crate) fn is_p2pk_locked(proof: &Proof) -> bool { + match >::try_into( + proof.secret.clone(), + ) { + Ok(s) => s.kind() == Kind::P2PK, + Err(_) => false, + } +} + +/// Collect all pubkeys that require a signature across a set of proofs. +/// +/// For each proof with a recognised NUT-10 secret: +/// - P2PK: returns the data key (slot 0) and any condition/refund keys +/// - HTLC: returns condition/refund keys only (slot 0 is a hash, not a pubkey) +/// +/// Proofs without a NUT-10 secret are skipped. +pub(crate) fn collect_p2pk_pubkeys(proofs: &Proofs) -> Result, Error> { + let mut result = Vec::new(); + + for proof in proofs.iter() { + 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; + }; + + match secret.kind() { + Kind::P2PK => { + let data_key = PublicKey::from_str(secret.secret_data().data())?; + result.push(data_key); + } + Kind::HTLC => { + // HTLC slot 0 is a hash, not a pubkey. + } + } + + if let Some(cond_pubkeys) = conditions.pubkeys { + result.extend(cond_pubkeys); + } + if let Some(refund_keys) = conditions.refund_keys { + result.extend(refund_keys); + } + } + + Ok(result) +} + +/// Sign P2PK-locked proofs using the provided signing keys. +/// +/// For each proof with a recognised 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(()) +} + /// Extract token from text pub fn token_from_text(text: &str) -> Option<&str> { let text = text.trim(); @@ -15,9 +171,78 @@ pub fn token_from_text(text: &str) -> Option<&str> { #[cfg(test)] mod tests { + use std::str::FromStr; + + use crate::nuts::{Id, Proof, SpendingConditions}; + use crate::Amount; use super::*; + 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()); + + sign_proofs(&mut proofs, &[secret_key]).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)]; + sign_proofs(&mut proofs, &[wrong_key]).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()]; + + sign_proofs(&mut proofs, &[signing_key]).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)]; + + sign_proofs(&mut proofs, &[]).unwrap(); + + assert!(proofs[0].witness.is_none()); + } + #[test] fn test_token_from_text() { let text = " Here is some ecash: cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJhbW91bnQiOjIsInNlY3JldCI6ImI2Zjk1ODIxYmZlNjUyYjYwZGQ2ZjYwMDU4N2UyZjNhOTk4MzVhMGMyNWI4MTQzODNlYWIwY2QzOWFiNDFjNzUiLCJDIjoiMDI1YWU4ZGEyOTY2Y2E5OGVmYjA5ZDcwOGMxM2FiZmEwZDkxNGUwYTk3OTE4MmFjMzQ4MDllMjYxODY5YTBhNDJlIiwicmVzZXJ2ZWQiOmZhbHNlLCJpZCI6IjAwOWExZjI5MzI1M2U0MWUifSx7ImFtb3VudCI6Miwic2VjcmV0IjoiZjU0Y2JjNmNhZWZmYTY5MTUyOTgyM2M1MjU1MDkwYjRhMDZjNGQ3ZDRjNzNhNDFlZTFkNDBlM2ExY2EzZGZhNyIsIkMiOiIwMjMyMTIzN2JlYjcyMWU3NGI1NzcwNWE5MjJjNjUxMGQwOTYyYzAzNzlhZDM0OTJhMDYwMDliZTAyNjA5ZjA3NTAiLCJyZXNlcnZlZCI6ZmFsc2UsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSJ9LHsiYW1vdW50IjoxLCJzZWNyZXQiOiJhNzdhM2NjODY4YWM4ZGU3YmNiOWMxMzJmZWI3YzEzMDY4Nzg3ODk5Yzk3YTk2NWE2ZThkZTFiMzliMmQ2NmQ3IiwiQyI6IjAzMTY0YTMxNWVhNjM0NGE5NWI2NzM1NzBkYzg0YmZlMTQ2NDhmMTQwM2EwMDJiZmJlMDhlNWFhMWE0NDQ0YWE0MCIsInJlc2VydmVkIjpmYWxzZSwiaWQiOiIwMDlhMWYyOTMyNTNlNDFlIn1dLCJtaW50IjoiaHR0cHM6Ly90ZXN0bnV0LmNhc2h1LnNwYWNlIn1dLCJ1bml0Ijoic2F0In0= fdfdfg From df888edbffda93e8cff0111069e312e33dfa2d39 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Mon, 18 May 2026 14:14:04 +0100 Subject: [PATCH 2/4] fix: coin selection when locked --- crates/cdk-ffi/src/lib.rs | 19 ++ crates/cdk-ffi/src/types/wallet.rs | 2 + crates/cdk/src/wallet/send/saga/mod.rs | 433 ++++++++++++++++++++++--- crates/cdk/src/wallet/util.rs | 3 +- 4 files changed, 406 insertions(+), 51 deletions(-) diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index 36d4e427ef..662f2e79b1 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -278,6 +278,25 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_send_options_json_defaults_new_p2pk_fields() { + let json = r#"{ + "memo": null, + "conditions": null, + "amount_split_target": "None", + "send_kind": "OnlineExact", + "include_fee": false, + "use_p2bk": false, + "max_proofs": null, + "metadata": {} + }"#; + + let options = crate::types::wallet::decode_send_options(json.to_string()).unwrap(); + + assert!(options.p2pk_signing_keys.is_empty()); + assert!(!options.allow_locked_proofs); + } + #[test] fn test_proof_with_invalid_dleq_returns_error() { let proof = Proof { diff --git a/crates/cdk-ffi/src/types/wallet.rs b/crates/cdk-ffi/src/types/wallet.rs index f32801f022..7b89d170a1 100644 --- a/crates/cdk-ffi/src/types/wallet.rs +++ b/crates/cdk-ffi/src/types/wallet.rs @@ -161,8 +161,10 @@ pub struct SendOptions { /// Metadata pub metadata: HashMap, /// Signing keys for P2PK-locked input proofs + #[serde(default)] pub p2pk_signing_keys: Vec, /// Allow P2PK-locked proofs to be included directly in the token without a swap + #[serde(default)] pub allow_locked_proofs: bool, } diff --git a/crates/cdk/src/wallet/send/saga/mod.rs b/crates/cdk/src/wallet/send/saga/mod.rs index b523bee599..cc4326d098 100644 --- a/crates/cdk/src/wallet/send/saga/mod.rs +++ b/crates/cdk/src/wallet/send/saga/mod.rs @@ -64,6 +64,7 @@ use std::collections::{HashMap, HashSet}; use bitcoin::XOnlyPublicKey; +use cdk_common::amount::KeysetFeeAndAmounts; use cdk_common::nut02::KeySetInfosMethods; use cdk_common::util::unix_time; use cdk_common::wallet::{ @@ -168,6 +169,190 @@ async fn merge_keyring_keys( Ok(keys) } +struct SendSplitContext<'a> { + send_amounts: &'a [Amount], + amount: Amount, + send_fee: Amount, + keyset_fees: &'a HashMap, + force_swap: bool, + is_exact_or_offline: bool, +} + +struct InputFeeCoverageContext<'a> { + amount: Amount, + send_fee: Amount, + active_keyset_ids: &'a Vec, + keyset_fees: &'a KeysetFeeAndAmounts, + send_amounts: &'a [Amount], + force_swap: bool, + is_exact_or_offline: bool, +} + +fn ensure_selected_proofs_cover_input_fees( + mut selected_proofs: Proofs, + proof_pool: Proofs, + context: InputFeeCoverageContext<'_>, +) -> Result { + let keyset_fee_map: HashMap = context + .keyset_fees + .iter() + .map(|(key, values)| (*key, values.fee())) + .collect(); + let mut remaining_proofs: Proofs = proof_pool + .into_iter() + .filter(|proof| !selected_proofs.contains(proof)) + .collect(); + + loop { + let selected_net = selected_proofs_net_after_swap_fees( + selected_proofs.clone(), + SendSplitContext { + send_amounts: context.send_amounts, + amount: context.amount, + send_fee: context.send_fee, + keyset_fees: &keyset_fee_map, + force_swap: context.force_swap, + is_exact_or_offline: context.is_exact_or_offline, + }, + )?; + + if selected_net >= context.amount + context.send_fee { + return Ok(selected_proofs); + } + + if remaining_proofs.is_empty() { + return Err(Error::InsufficientFunds); + } + + let shortfall = (context.amount + context.send_fee) + .checked_sub(selected_net) + .unwrap_or(Amount::ZERO); + let additional = Wallet::select_proofs( + shortfall, + remaining_proofs.clone(), + context.active_keyset_ids, + context.keyset_fees, + false, + )?; + + if additional.is_empty() { + return Err(Error::InsufficientFunds); + } + + remaining_proofs.retain(|proof| !additional.contains(proof)); + selected_proofs.extend(additional); + } +} + +fn split_proofs_for_send_respecting_p2pk_locks( + proofs: Proofs, + allow_locked_proofs: bool, + context: SendSplitContext<'_>, +) -> Result { + // When the wallet holds P2PK-locked proofs and passthrough is not opted in, route them + // through a swap so the token contains fresh, unlocked proofs. The signing key may come + // from `SendOptions.p2pk_signing_keys` or be discovered automatically from the wallet + // keyring at confirm time; we partition eagerly regardless so that the swap is set up + // correctly even when only keyring keys will be used. + // + // Unlocked proofs bypass the swap entirely — they are already bearer. + // + // HTLC-locked proofs are intentionally excluded from this forced-swap path even though + // they also carry a NUT-10 secret. Spending an HTLC requires a preimage that signing + // keys alone cannot provide; routing HTLC proofs to a swap here would cause a mint + // rejection. HTLC support in the send path (including a `htlc_preimages` field on + // `SendOptions` and its own partition logic) is left for a follow-up PR. + let has_p2pk_locked = proofs.iter().any(crate::wallet::util::is_p2pk_locked); + if has_p2pk_locked && !allow_locked_proofs { + let (p2pk_locked, rest): (Proofs, Proofs) = proofs + .into_iter() + .partition(crate::wallet::util::is_p2pk_locked); + let mut proofs_to_swap = p2pk_locked; + let mut proofs_to_send = Proofs::new(); + + if context.force_swap { + proofs_to_swap.extend(rest); + } else if context.is_exact_or_offline { + proofs_to_send = rest; + } else { + let mut remaining_send_amounts: Vec = context.send_amounts.to_vec(); + for proof in rest { + if let Some(idx) = remaining_send_amounts + .iter() + .position(|a| a == &proof.amount) + { + proofs_to_send.push(proof); + remaining_send_amounts.remove(idx); + } else { + proofs_to_swap.push(proof); + } + } + + if !proofs_to_swap.is_empty() { + let swap_output_needed = (context.amount + context.send_fee) + .checked_sub(proofs_to_send.total_amount()?) + .unwrap_or(Amount::ZERO); + + if swap_output_needed != Amount::ZERO { + loop { + let swap_input_fee = + calculate_fee(&proofs_to_swap.count_by_keyset(), context.keyset_fees)? + .total; + let swap_total = proofs_to_swap.total_amount()?; + let swap_can_produce = swap_total.checked_sub(swap_input_fee); + + match swap_can_produce { + Some(can_produce) if can_produce >= swap_output_needed => { + break; + } + _ => { + if proofs_to_send.is_empty() { + return Err(Error::InsufficientFunds); + } + + proofs_to_send.sort_by_key(|a| a.amount); + let proof_to_move = proofs_to_send.remove(0); + proofs_to_swap.push(proof_to_move); + } + } + } + } + } + } + + let swap_fee = calculate_fee(&proofs_to_swap.count_by_keyset(), context.keyset_fees)?.total; + Ok(super::ProofSplitResult { + proofs_to_send, + proofs_to_swap, + swap_fee, + }) + } else { + split_proofs_for_send( + proofs, + context.send_amounts, + context.amount, + context.send_fee, + context.keyset_fees, + context.force_swap, + context.is_exact_or_offline, + ) + } +} + +fn selected_proofs_net_after_swap_fees( + selected_proofs: Proofs, + context: SendSplitContext<'_>, +) -> Result { + let split = split_proofs_for_send_respecting_p2pk_locks(selected_proofs, false, context)?; + let direct_total = split.proofs_to_send.total_amount()?; + let swap_total = split.proofs_to_swap.total_amount()?; + let swap_net = swap_total + .checked_sub(split.swap_fee) + .unwrap_or(Amount::ZERO); + + Ok(direct_total + swap_net) +} + /// Saga pattern implementation for send operations. /// /// Uses the typestate pattern to enforce valid state transitions at compile-time. @@ -237,6 +422,9 @@ impl<'a> SendSaga<'a, Initial> { available_proofs = filter_signable_proofs(self.wallet, available_proofs, &opts.p2pk_signing_keys) .await?; + if opts.send_kind.is_offline() { + available_proofs.retain(|proof| !crate::wallet::util::is_p2pk_locked(proof)); + } } let mut force_swap = false; @@ -286,7 +474,7 @@ impl<'a> SendSaga<'a, Initial> { .get_keyset_fees_and_amounts_by_id(active_keyset_id) .await?; - let selection_amount = if opts.include_fee { + let send_amounts = if opts.include_fee { let send_split = amount.split_with_fee(&fee_and_amounts)?; let send_fee = self .wallet @@ -296,19 +484,25 @@ impl<'a> SendSaga<'a, Initial> { .collect(), ) .await?; - amount + send_fee.total + (send_split, send_fee.total) } else { - amount + (amount.split(&fee_and_amounts)?, Amount::ZERO) }; + let selection_amount = amount + send_amounts.1; - let selected_proofs = Wallet::select_proofs( + let may_swap_p2pk_locked = !opts.allow_locked_proofs + && available_proofs + .iter() + .any(crate::wallet::util::is_p2pk_locked); + + let proof_pool = available_proofs.clone(); + let mut selected_proofs = Wallet::select_proofs( selection_amount, available_proofs, &active_keyset_ids, &keyset_fees, opts.include_fee || force_swap, )?; - let selected_total = selected_proofs.total_amount()?; let send_fee = if opts.include_fee { self.wallet.get_proofs_fee(&selected_proofs).await?.total @@ -316,6 +510,27 @@ impl<'a> SendSaga<'a, Initial> { Amount::ZERO }; + if may_swap_p2pk_locked { + let is_exact_or_offline = selected_proofs.total_amount()? == amount + send_fee + || opts.send_kind.is_offline() + || opts.send_kind.has_tolerance(); + selected_proofs = ensure_selected_proofs_cover_input_fees( + selected_proofs, + proof_pool, + InputFeeCoverageContext { + amount, + send_fee, + active_keyset_ids: &active_keyset_ids, + keyset_fees: &keyset_fees, + send_amounts: &send_amounts.0, + force_swap, + is_exact_or_offline, + }, + )?; + } + + let selected_total = selected_proofs.total_amount()?; + if selected_total == amount + send_fee { return self .internal_prepare(amount, opts, selected_proofs, force_swap) @@ -422,51 +637,18 @@ impl<'a> SendSaga<'a, Initial> { .map(|(key, values)| (*key, values.fee())) .collect(); - // When the wallet holds P2PK-locked proofs and passthrough is not opted in, route them - // through a swap so the token contains fresh, unlocked proofs. The signing key may come - // from `SendOptions.p2pk_signing_keys` or be discovered automatically from the wallet - // keyring at confirm time; we partition eagerly regardless so that the swap is set up - // correctly even when only keyring keys will be used. - // - // Unlocked proofs bypass the swap entirely — they are already bearer. - // - // HTLC-locked proofs are intentionally excluded from this forced-swap path even though - // they also carry a NUT-10 secret. Spending an HTLC requires a preimage that signing - // keys alone cannot provide; routing HTLC proofs to a swap here would cause a mint - // rejection. HTLC support in the send path (including a `htlc_preimages` field on - // `SendOptions` and its own partition logic) is left for a follow-up PR. - let has_p2pk_locked = proofs.iter().any(crate::wallet::util::is_p2pk_locked); - let split_result = if has_p2pk_locked && !opts.allow_locked_proofs { - let (p2pk_locked, rest): (Vec<_>, Vec<_>) = proofs - .into_iter() - .partition(crate::wallet::util::is_p2pk_locked); - - let mut split = split_proofs_for_send( - rest, - &send_amounts, + let split_result = split_proofs_for_send_respecting_p2pk_locks( + proofs, + opts.allow_locked_proofs, + SendSplitContext { + send_amounts: &send_amounts, amount, - send_fee.total, - &keyset_fees, - force_swap, - is_exact_or_offline, - )?; - - // Append the locked proofs to the swap set and recalculate the swap fee. - split.proofs_to_swap.extend(p2pk_locked); - split.swap_fee = - calculate_fee(&split.proofs_to_swap.count_by_keyset(), &keyset_fees)?.total; - split - } else { - split_proofs_for_send( - proofs, - &send_amounts, - amount, - send_fee.total, - &keyset_fees, + send_fee: send_fee.total, + keyset_fees: &keyset_fees, force_swap, is_exact_or_offline, - )? - }; + }, + )?; Ok(SendSaga { wallet: self.wallet, @@ -937,16 +1119,39 @@ impl std::fmt::Debug for SendSaga<'_, Prepared> { mod tests { use std::sync::Arc; + use cdk_common::amount::KeysetFeeAndAmounts; use cdk_common::nuts::State; + use cdk_common::wallet::{ProofInfo, SendKind}; + use cdk_common::{CurrencyUnit, ProofsMethods}; - use super::SendSaga; + use super::{ensure_selected_proofs_cover_input_fees, InputFeeCoverageContext, SendSaga}; + use crate::nuts::{Proof, SecretKey, SpendingConditions}; use crate::wallet::send::SendOptions; use crate::wallet::test_utils::{ - create_test_db, create_test_wallet_with_mock, test_keyset_id, test_mint_url, + create_test_db, create_test_wallet_with_mock, test_keyset_id, test_mint_url, test_proof, test_proof_info, MockMintConnector, }; use crate::Amount; + fn keyset_fees_with_ppk(fee_ppk: u64) -> KeysetFeeAndAmounts { + let mut fees = KeysetFeeAndAmounts::new(); + fees.insert( + test_keyset_id(), + (fee_ppk, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); + fees + } + + fn test_p2pk_proof(keyset_id: crate::nuts::Id, amount: u64) -> Proof { + let secret_key = SecretKey::generate(); + let spending_conditions = SpendingConditions::new_p2pk(secret_key.public_key(), None); + let nut10_secret: crate::nuts::nut10::Secret = spending_conditions.into(); + let secret: crate::secret::Secret = nut10_secret.try_into().unwrap(); + let mut proof = test_proof(keyset_id, amount); + proof.secret = secret; + proof + } + #[tokio::test] async fn test_prepare_send_reserves_proofs_for_operation() { let db = create_test_db().await; @@ -983,4 +1188,134 @@ mod tests { Some(prepared.operation_id()) ); } + + #[tokio::test] + async fn test_offline_send_excludes_locked_proofs_without_passthrough() { + let db = create_test_db().await; + let mint_url = test_mint_url(); + let keyset_id = test_keyset_id(); + let proof = test_p2pk_proof(keyset_id, 8); + let proof_info = + ProofInfo::new(proof, mint_url, State::Unspent, CurrencyUnit::Sat).unwrap(); + + db.update_proofs(vec![proof_info], vec![]).await.unwrap(); + + let mock_client = Arc::new(MockMintConnector::new()); + mock_client.reset_default_mint_state(); + + let wallet = create_test_wallet_with_mock(db, mock_client).await; + let saga = SendSaga::new(&wallet); + let err = saga + .prepare( + Amount::from(8), + SendOptions { + send_kind: SendKind::OfflineExact, + ..Default::default() + }, + ) + .await + .expect_err("offline send must not prepare a locked-proof swap by default"); + + assert!(matches!(err, crate::Error::InsufficientFunds)); + } + + #[test] + fn test_ensure_selected_proofs_cover_input_fees_adds_remaining_proofs() { + let keyset_id = test_keyset_id(); + let selected_proofs = vec![ + test_proof(keyset_id, 32), + test_proof(keyset_id, 16), + test_proof(keyset_id, 8), + test_proof(keyset_id, 4), + test_proof(keyset_id, 2), + test_proof(keyset_id, 1), + ]; + let mut proof_pool = selected_proofs.clone(); + proof_pool.push(test_proof(keyset_id, 8)); + let active_keyset_ids = vec![keyset_id]; + let keyset_fees = keyset_fees_with_ppk(1000); + let send_amounts = vec![Amount::from(63)]; + + let selected = ensure_selected_proofs_cover_input_fees( + selected_proofs, + proof_pool, + InputFeeCoverageContext { + amount: Amount::from(63), + send_fee: Amount::ZERO, + active_keyset_ids: &active_keyset_ids, + keyset_fees: &keyset_fees, + send_amounts: &send_amounts, + force_swap: true, + is_exact_or_offline: false, + }, + ) + .unwrap(); + + assert_eq!(selected.total_amount().unwrap(), Amount::from(71)); + assert_eq!(selected.len(), 7); + } + + #[test] + fn test_ensure_selected_proofs_cover_input_fees_errors_when_short() { + let keyset_id = test_keyset_id(); + let selected_proofs = vec![ + test_proof(keyset_id, 32), + test_proof(keyset_id, 16), + test_proof(keyset_id, 8), + test_proof(keyset_id, 4), + test_proof(keyset_id, 2), + test_proof(keyset_id, 1), + ]; + let active_keyset_ids = vec![keyset_id]; + let keyset_fees = keyset_fees_with_ppk(1000); + let send_amounts = vec![Amount::from(63)]; + + let err = ensure_selected_proofs_cover_input_fees( + selected_proofs.clone(), + selected_proofs, + InputFeeCoverageContext { + amount: Amount::from(63), + send_fee: Amount::ZERO, + active_keyset_ids: &active_keyset_ids, + keyset_fees: &keyset_fees, + send_amounts: &send_amounts, + force_swap: true, + is_exact_or_offline: false, + }, + ) + .expect_err("selected proofs cannot cover input fees without extra proofs"); + + assert!(matches!(err, crate::Error::InsufficientFunds)); + } + + #[test] + fn test_ensure_selected_proofs_only_charges_actual_swap_inputs() { + let keyset_id = test_keyset_id(); + let selected_proofs = vec![ + test_p2pk_proof(keyset_id, 8), + test_proof(keyset_id, 2), + test_proof(keyset_id, 2), + ]; + let active_keyset_ids = vec![keyset_id]; + let keyset_fees = keyset_fees_with_ppk(1000); + let send_amounts = vec![Amount::from(8), Amount::from(2)]; + + let selected = ensure_selected_proofs_cover_input_fees( + selected_proofs.clone(), + selected_proofs, + InputFeeCoverageContext { + amount: Amount::from(10), + send_fee: Amount::ZERO, + active_keyset_ids: &active_keyset_ids, + keyset_fees: &keyset_fees, + send_amounts: &send_amounts, + force_swap: false, + is_exact_or_offline: false, + }, + ) + .unwrap(); + + assert_eq!(selected.total_amount().unwrap(), Amount::from(12)); + assert_eq!(selected.len(), 3); + } } diff --git a/crates/cdk/src/wallet/util.rs b/crates/cdk/src/wallet/util.rs index b45169f8c8..a3143f1436 100644 --- a/crates/cdk/src/wallet/util.rs +++ b/crates/cdk/src/wallet/util.rs @@ -173,11 +173,10 @@ pub fn token_from_text(text: &str) -> Option<&str> { mod tests { use std::str::FromStr; + use super::*; use crate::nuts::{Id, Proof, SpendingConditions}; use crate::Amount; - use super::*; - 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(); From aa7d4cf783b7e20e07e479dfbbd08ff6c4a9b420 Mon Sep 17 00:00:00 2001 From: vnprc Date: Tue, 19 May 2026 14:17:30 +0000 Subject: [PATCH 3/4] feat: remove need for direct secp dep --- crates/cashu/src/nuts/nut13.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cashu/src/nuts/nut13.rs b/crates/cashu/src/nuts/nut13.rs index 6bfda0d729..7fa8cf8db1 100644 --- a/crates/cashu/src/nuts/nut13.rs +++ b/crates/cashu/src/nuts/nut13.rs @@ -36,8 +36,8 @@ pub enum Error { #[error(transparent)] Bip32(#[from] bitcoin::bip32::Error), /// HMAC Error - #[error(transparent)] - Hmac(#[from] bitcoin::secp256k1::hashes::FromSliceError), + #[error("HMAC error: {0}")] + Hmac(bitcoin::secp256k1::hashes::FromSliceError), /// SecretKey Error #[error(transparent)] SecpError(#[from] bitcoin::secp256k1::Error), From 25c274c5979d2762f4dda48560c1612c78fd2941 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Tue, 19 May 2026 21:19:43 +0200 Subject: [PATCH 4/4] refactor(wallet): replace locked proof passthrough bool with send mode Replace the send-side `allow_locked_proofs` boolean with an explicit `P2PKLockedProofSendMode` enum. The default remains swapping P2PK-locked proofs, while direct signed passthrough is now represented as `SignAndSend`. Update the wallet re-export, FFI conversions/defaults, and P2PK integration tests to use the new mode. --- crates/cdk-common/src/wallet/mod.rs | 14 +++++- crates/cdk-ffi/src/lib.rs | 12 +++-- crates/cdk-ffi/src/types/wallet.rs | 44 ++++++++++++++++--- .../tests/integration_tests_pure.rs | 28 ++++++------ crates/cdk/src/wallet/mod.rs | 2 +- crates/cdk/src/wallet/send/saga/mod.rs | 25 ++++++----- 6 files changed, 91 insertions(+), 34 deletions(-) diff --git a/crates/cdk-common/src/wallet/mod.rs b/crates/cdk-common/src/wallet/mod.rs index cb7f3c6ccc..6f04e13852 100644 --- a/crates/cdk-common/src/wallet/mod.rs +++ b/crates/cdk-common/src/wallet/mod.rs @@ -337,8 +337,18 @@ pub struct SendOptions { pub use_p2bk: bool, /// Signing keys for P2PK-locked input proofs; auto-detected from the wallet keyring if omitted pub p2pk_signing_keys: Vec, - /// If `true`, sign and forward P2PK-locked proofs directly without swapping them for fresh ones - pub allow_locked_proofs: bool, + /// How P2PK-locked input proofs should be handled during send + pub p2pk_locked_proof_send_mode: P2PKLockedProofSendMode, +} + +/// Send behavior for selected P2PK-locked input proofs +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum P2PKLockedProofSendMode { + /// Swap locked proofs into fresh proofs before creating the token + #[default] + Swap, + /// Sign locked proofs and include them directly in the token + SignAndSend, } /// Send memo diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index 662f2e79b1..e96d56c722 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -81,7 +81,10 @@ mod tests { assert!(options.max_proofs.is_none()); assert!(options.metadata.is_empty()); assert!(options.p2pk_signing_keys.is_empty()); - assert!(!options.allow_locked_proofs); + assert_eq!( + options.p2pk_locked_proof_send_mode, + P2PKLockedProofSendMode::Swap + ); } #[test] @@ -204,7 +207,7 @@ mod tests { metadata, use_p2bk: false, p2pk_signing_keys: Vec::new(), - allow_locked_proofs: false, + p2pk_locked_proof_send_mode: P2PKLockedProofSendMode::Swap, }; assert!(options.memo.is_some()); @@ -294,7 +297,10 @@ mod tests { let options = crate::types::wallet::decode_send_options(json.to_string()).unwrap(); assert!(options.p2pk_signing_keys.is_empty()); - assert!(!options.allow_locked_proofs); + assert_eq!( + options.p2pk_locked_proof_send_mode, + P2PKLockedProofSendMode::Swap + ); } #[test] diff --git a/crates/cdk-ffi/src/types/wallet.rs b/crates/cdk-ffi/src/types/wallet.rs index 7b89d170a1..0fc5fbc80c 100644 --- a/crates/cdk-ffi/src/types/wallet.rs +++ b/crates/cdk-ffi/src/types/wallet.rs @@ -142,6 +142,40 @@ impl From for SendKind { } } +/// FFI-compatible P2PK locked proof send mode +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Enum, Default, +)] +pub enum P2PKLockedProofSendMode { + /// Swap locked proofs into fresh proofs before creating the token + #[default] + Swap, + /// Sign locked proofs and include them directly in the token + SignAndSend, +} + +impl From for cdk::wallet::P2PKLockedProofSendMode { + fn from(mode: P2PKLockedProofSendMode) -> Self { + match mode { + P2PKLockedProofSendMode::Swap => cdk::wallet::P2PKLockedProofSendMode::Swap, + P2PKLockedProofSendMode::SignAndSend => { + cdk::wallet::P2PKLockedProofSendMode::SignAndSend + } + } + } +} + +impl From for P2PKLockedProofSendMode { + fn from(mode: cdk::wallet::P2PKLockedProofSendMode) -> Self { + match mode { + cdk::wallet::P2PKLockedProofSendMode::Swap => P2PKLockedProofSendMode::Swap, + cdk::wallet::P2PKLockedProofSendMode::SignAndSend => { + P2PKLockedProofSendMode::SignAndSend + } + } + } +} + /// FFI-compatible Send options #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct SendOptions { @@ -163,9 +197,9 @@ pub struct SendOptions { /// Signing keys for P2PK-locked input proofs #[serde(default)] pub p2pk_signing_keys: Vec, - /// Allow P2PK-locked proofs to be included directly in the token without a swap + /// How P2PK-locked input proofs should be handled during send #[serde(default)] - pub allow_locked_proofs: bool, + pub p2pk_locked_proof_send_mode: P2PKLockedProofSendMode, } impl Default for SendOptions { @@ -180,7 +214,7 @@ impl Default for SendOptions { metadata: HashMap::new(), use_p2bk: false, p2pk_signing_keys: Vec::new(), - allow_locked_proofs: false, + p2pk_locked_proof_send_mode: P2PKLockedProofSendMode::Swap, } } } @@ -205,7 +239,7 @@ impl TryFrom for cdk::wallet::SendOptions { metadata: opts.metadata, use_p2bk: opts.use_p2bk, p2pk_signing_keys, - allow_locked_proofs: opts.allow_locked_proofs, + p2pk_locked_proof_send_mode: opts.p2pk_locked_proof_send_mode.into(), }) } } @@ -222,7 +256,7 @@ impl From for SendOptions { metadata: opts.metadata, use_p2bk: opts.use_p2bk, p2pk_signing_keys: opts.p2pk_signing_keys.into_iter().map(Into::into).collect(), - allow_locked_proofs: opts.allow_locked_proofs, + p2pk_locked_proof_send_mode: opts.p2pk_locked_proof_send_mode.into(), } } } diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index c95e4a55cd..fec25966ce 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -32,7 +32,9 @@ use cdk::nuts::nut00::ProofsMethods; use cdk::subscription::Params; use cdk::types::QuoteTTL; use cdk::wallet::types::{TransactionDirection, TransactionId}; -use cdk::wallet::{KeysetFilter, MintConnector, ReceiveOptions, SendMemo, SendOptions}; +use cdk::wallet::{ + KeysetFilter, MintConnector, P2PKLockedProofSendMode, ReceiveOptions, SendMemo, SendOptions, +}; use cdk::{Amount, StreamExt}; use cdk_common::mint::OperationKind; use cdk_common::payment::{ @@ -2611,7 +2613,7 @@ async fn test_p2pk_send_options_signing_keys() { /// `prepare_send` would normally take a short-circuit path: all proofs go directly to /// `proofs_to_send` (no swap), so signing is skipped and locked proofs end up in the token. /// -/// Fix: when `p2pk_signing_keys` is non-empty and `allow_locked_proofs` is false (the default), +/// Fix: when `p2pk_signing_keys` is non-empty and locked proofs are swapped (the default), /// force a swap so the token always contains fresh, unconditioned proofs. #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_p2pk_signing_keys_exact_denomination_short_circuit() { @@ -2720,13 +2722,13 @@ async fn test_p2pk_signing_keys_exact_denomination_short_circuit() { ); } -/// Test that `allow_locked_proofs: true` opts in to passing signed P2PK-locked proofs +/// Test that `SignAndSend` opts in to passing signed P2PK-locked proofs /// directly in the token without a swap. /// /// Bob must provide the corresponding signing key when receiving, since the proofs still /// carry their spending conditions. #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_p2pk_allow_locked_proofs_passthrough() { +async fn test_p2pk_locked_proof_sign_and_send_passthrough() { setup_tracing(); let mint = create_and_start_test_mint() @@ -2796,13 +2798,13 @@ async fn test_p2pk_allow_locked_proofs_passthrough() { .await .unwrap(); - // Alice sends with allow_locked_proofs: proofs are signed but not swapped + // Alice sends with SignAndSend: proofs are signed but not swapped. let prepared = wallet_alice .prepare_send( total_amount, SendOptions { p2pk_signing_keys: vec![shared_secret.clone()], - allow_locked_proofs: true, + p2pk_locked_proof_send_mode: P2PKLockedProofSendMode::SignAndSend, ..Default::default() }, ) @@ -2812,7 +2814,7 @@ async fn test_p2pk_allow_locked_proofs_passthrough() { let token = prepared .confirm(None) .await - .expect("confirm should succeed with allow_locked_proofs"); + .expect("confirm should succeed with SignAndSend"); // Alice pre-signed the proofs before sending, so the witness is already attached and the // proof is effectively bearer. Bob does NOT need to provide the signing key — the mint @@ -2825,14 +2827,14 @@ async fn test_p2pk_allow_locked_proofs_passthrough() { assert_eq!(total_amount, received, "Bob should receive the full amount"); } -/// Test that `allow_locked_proofs` is rejected when any passthrough proof carries `SIG_ALL`. +/// Test that `SignAndSend` is rejected when any passthrough proof carries `SIG_ALL`. /// /// A `SIG_ALL` signature must commit to the swap outputs, which do not exist at signing time. /// The recipient cannot construct valid outputs for such a proof, so any redemption attempt /// would be rejected by the mint. `confirm` should fail fast rather than silently producing /// an unspendable token. #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_p2pk_allow_locked_proofs_rejects_sig_all() { +async fn test_p2pk_locked_proof_sign_and_send_rejects_sig_all() { setup_tracing(); let mint = create_and_start_test_mint() @@ -2902,13 +2904,13 @@ async fn test_p2pk_allow_locked_proofs_rejects_sig_all() { .await .unwrap(); - // prepare_send succeeds: allow_locked_proofs skips the force-swap path + // prepare_send succeeds: SignAndSend skips the force-swap path. let prepared = wallet_alice .prepare_send( total_amount, SendOptions { p2pk_signing_keys: vec![alice_secret], - allow_locked_proofs: true, + p2pk_locked_proof_send_mode: P2PKLockedProofSendMode::SignAndSend, ..Default::default() }, ) @@ -3401,7 +3403,7 @@ async fn test_p2pk_partially_signable_proof_only_gives_insufficient_funds() { /// Passthrough mode must not create a token containing partially signed locked proofs. #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_p2pk_allow_locked_proofs_rejects_partial_signatures() { +async fn test_p2pk_locked_proof_sign_and_send_rejects_partial_signatures() { setup_tracing(); let mint = create_and_start_test_mint() @@ -3482,7 +3484,7 @@ async fn test_p2pk_allow_locked_proofs_rejects_partial_signatures() { total_amount, SendOptions { p2pk_signing_keys: vec![alice_secret], - allow_locked_proofs: true, + p2pk_locked_proof_send_mode: P2PKLockedProofSendMode::SignAndSend, ..Default::default() }, ) diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index 03aa00bb53..3bd11f6c85 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -79,7 +79,7 @@ pub use bip321::{ }; pub use builder::WalletBuilder; pub use cdk_common::wallet as types; -pub use cdk_common::wallet::{ReceiveOptions, SendMemo, SendOptions}; +pub use cdk_common::wallet::{P2PKLockedProofSendMode, ReceiveOptions, SendMemo, SendOptions}; pub use keysets::KeysetFilter; pub use melt::{MeltConfirmOptions, MeltOutcome, PendingMelt, PreparedMelt}; pub use mint_connector::transport::Transport as HttpTransport; diff --git a/crates/cdk/src/wallet/send/saga/mod.rs b/crates/cdk/src/wallet/send/saga/mod.rs index cc4326d098..b349a82ba2 100644 --- a/crates/cdk/src/wallet/send/saga/mod.rs +++ b/crates/cdk/src/wallet/send/saga/mod.rs @@ -68,8 +68,8 @@ use cdk_common::amount::KeysetFeeAndAmounts; use cdk_common::nut02::KeySetInfosMethods; use cdk_common::util::unix_time; use cdk_common::wallet::{ - OperationData, SendOperationData, SendSagaState, Transaction, TransactionDirection, WalletSaga, - WalletSagaState, + OperationData, P2PKLockedProofSendMode, SendOperationData, SendSagaState, Transaction, + TransactionDirection, WalletSaga, WalletSagaState, }; use cdk_common::Id; use tracing::instrument; @@ -246,7 +246,7 @@ fn ensure_selected_proofs_cover_input_fees( fn split_proofs_for_send_respecting_p2pk_locks( proofs: Proofs, - allow_locked_proofs: bool, + p2pk_locked_proof_send_mode: P2PKLockedProofSendMode, context: SendSplitContext<'_>, ) -> Result { // When the wallet holds P2PK-locked proofs and passthrough is not opted in, route them @@ -263,7 +263,7 @@ fn split_proofs_for_send_respecting_p2pk_locks( // rejection. HTLC support in the send path (including a `htlc_preimages` field on // `SendOptions` and its own partition logic) is left for a follow-up PR. let has_p2pk_locked = proofs.iter().any(crate::wallet::util::is_p2pk_locked); - if has_p2pk_locked && !allow_locked_proofs { + if has_p2pk_locked && p2pk_locked_proof_send_mode == P2PKLockedProofSendMode::Swap { let (p2pk_locked, rest): (Proofs, Proofs) = proofs .into_iter() .partition(crate::wallet::util::is_p2pk_locked); @@ -343,7 +343,11 @@ fn selected_proofs_net_after_swap_fees( selected_proofs: Proofs, context: SendSplitContext<'_>, ) -> Result { - let split = split_proofs_for_send_respecting_p2pk_locks(selected_proofs, false, context)?; + let split = split_proofs_for_send_respecting_p2pk_locks( + selected_proofs, + P2PKLockedProofSendMode::Swap, + context, + )?; let direct_total = split.proofs_to_send.total_amount()?; let swap_total = split.proofs_to_swap.total_amount()?; let swap_net = swap_total @@ -418,7 +422,7 @@ impl<'a> SendSaga<'a, Initial> { // cannot be signed before the swap and would cause a mint rejection at confirm time. // Excluding them here lets the selection algorithm work with only spendable proofs and // surfaces a clean InsufficientFunds error if nothing else is available. - if !opts.allow_locked_proofs { + if opts.p2pk_locked_proof_send_mode == P2PKLockedProofSendMode::Swap { available_proofs = filter_signable_proofs(self.wallet, available_proofs, &opts.p2pk_signing_keys) .await?; @@ -449,7 +453,7 @@ impl<'a> SendSaga<'a, Initial> { .map(|p| p.proof) .collect(); - if !opts.allow_locked_proofs { + if opts.p2pk_locked_proof_send_mode == P2PKLockedProofSendMode::Swap { available_proofs = filter_signable_proofs( self.wallet, available_proofs, @@ -490,7 +494,8 @@ impl<'a> SendSaga<'a, Initial> { }; let selection_amount = amount + send_amounts.1; - let may_swap_p2pk_locked = !opts.allow_locked_proofs + let may_swap_p2pk_locked = opts.p2pk_locked_proof_send_mode + == P2PKLockedProofSendMode::Swap && available_proofs .iter() .any(crate::wallet::util::is_p2pk_locked); @@ -639,7 +644,7 @@ impl<'a> SendSaga<'a, Initial> { let split_result = split_proofs_for_send_respecting_p2pk_locks( proofs, - opts.allow_locked_proofs, + opts.p2pk_locked_proof_send_mode, SendSplitContext { send_amounts: &send_amounts, amount, @@ -773,7 +778,7 @@ impl<'a> SendSaga<'a, Prepared> { // create valid outputs for a proof signed with SIG_ALL, so any attempt to redeem // it at the mint would fail. Reject early with a clear error rather than silently // producing an unspendable token. - if options.allow_locked_proofs { + if options.p2pk_locked_proof_send_mode == P2PKLockedProofSendMode::SignAndSend { let sig_flag = enforce_sig_flag(final_proofs_to_send.clone()).sig_flag; if sig_flag == SigFlag::SigAll { return Err(crate::nuts::nut11::Error::SigAllNotSupportedHere.into());