Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/cdk-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ mod tests {
max_proofs: Some(10),
metadata,
use_p2bk: false,
p2pk_signing_keys: Vec::new(),
};

assert!(options.memo.is_some());
Expand Down
5 changes: 5 additions & 0 deletions crates/cdk-ffi/src/types/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ pub struct SendOptions {
pub max_proofs: Option<u32>,
/// Metadata
pub metadata: HashMap<String, String>,
/// Signing keys for P2PK-locked input proofs
pub p2pk_signing_keys: Vec<SecretKey>,
}

impl Default for SendOptions {
Expand All @@ -130,6 +132,7 @@ impl Default for SendOptions {
max_proofs: None,
metadata: HashMap::new(),
use_p2bk: false,
p2pk_signing_keys: Vec::new(),
}
}
}
Expand All @@ -145,6 +148,7 @@ impl From<SendOptions> 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(),
}
}
}
Expand All @@ -160,6 +164,7 @@ impl From<cdk::wallet::SendOptions> 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(),
}
}
}
Expand Down
237 changes: 237 additions & 0 deletions crates/cdk-integration-tests/tests/integration_tests_pure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2112,3 +2113,239 @@ 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::<Vec<_>>()).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"
);
}

/// 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.
Comment on lines +2232 to +2241

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should address this. Sending proofs with a pubkey lock as well as their signature can have a negative privacy implication. So we should not just fall back to including it. This should be an explicit option to include tokens that have locks.

#[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::<Vec<_>>()).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"
);
}
Loading
Loading