diff --git a/crates/cashu/src/nuts/nut11/mod.rs b/crates/cashu/src/nuts/nut11/mod.rs index abc6d2930..c7a306ce6 100644 --- a/crates/cashu/src/nuts/nut11/mod.rs +++ b/crates/cashu/src/nuts/nut11/mod.rs @@ -125,8 +125,10 @@ impl Proof { Ok(()) } - /// Verify P2PK signature on [Proof] - pub fn verify_p2pk(&self) -> Result<(), Error> { + /// Verify P2PK signature on individual [Proof] with SIG_INPUT flag + /// This function is ONLY for SigInputs verification at the individual proof level. + /// For SigAll, verification happens at the transaction level in verify_sig_all(). + pub fn verify_p2pk_for_sig_input(&self) -> Result<(), Error> { let secret: Nut10Secret = self.secret.clone().try_into()?; let spending_conditions: Conditions = secret .secret_data() @@ -136,6 +138,13 @@ impl Proof { .try_into()?; let msg: &[u8] = self.secret.as_bytes(); + // Ensure this is only called for SigInputs proofs + assert_eq!( + spending_conditions.sig_flag, + SigFlag::SigInputs, + "verify_p2pk_for_sig_input() should only be called for SigInputs proofs. SigAll verification happens at transaction level." + ); + let mut verified_pubkeys = HashSet::new(); let witness_signatures = match &self.witness { @@ -702,56 +711,32 @@ impl FromStr for SigFlag { } } -/// Get the signature flag that should be enforced for a set of proofs and the -/// public keys that signatures are valid for -pub fn enforce_sig_flag(proofs: Proofs) -> EnforceSigFlag { - let mut sig_flag = SigFlag::SigInputs; - let mut pubkeys = HashSet::new(); - let mut sigs_required = 1; +/// Check if at least one proof in the transaction uses SigAll. +/// +/// This function can be called with any mix of proof types: +/// - Regular proofs (no spending conditions) - will be skipped +/// - P2PK proofs (NUT-11) - will be checked for SigAll flag +/// - HTLC proofs (NUT-14) - will be checked for SigAll flag +/// +/// Returns true if ANY proof has sig_flag == SigAll, false otherwise. +/// For SigAll transactions, all proofs with spending conditions must have +/// matching conditions (enforced by verify_matching_conditions). +pub fn has_at_least_one_sig_all_proof(proofs: Proofs) -> bool { for proof in proofs { + // Try to parse as NUT-10 secret (P2PK or HTLC) if let Ok(secret) = Nut10Secret::try_from(proof.secret) { - if secret.kind().eq(&Kind::P2PK) { - if let Ok(verifying_key) = PublicKey::from_str(secret.secret_data().data()) { - pubkeys.insert(verifying_key); - } - } - + // Check if this proof has spending conditions with SigAll if let Some(tags) = secret.secret_data().tags() { if let Ok(conditions) = Conditions::try_from(tags.clone()) { - if conditions.sig_flag.eq(&SigFlag::SigAll) { - sig_flag = SigFlag::SigAll; - } - - if let Some(sigs) = conditions.num_sigs { - if sigs > sigs_required { - sigs_required = sigs; - } - } - - if let Some(pubs) = conditions.pubkeys { - pubkeys.extend(pubs); + if conditions.sig_flag == SigFlag::SigAll { + return true; } } } } + // Regular proofs (non-NUT-10) are skipped } - - EnforceSigFlag { - sig_flag, - pubkeys, - sigs_required, - } -} - -/// Enforce Sigflag info -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EnforceSigFlag { - /// Sigflag required for proofs - pub sig_flag: SigFlag, - /// Pubkeys that can sign for proofs - pub pubkeys: HashSet, - /// Number of sigs required for proofs - pub sigs_required: u64, + false } /// Tag @@ -875,6 +860,62 @@ impl From for Vec { } } +/// Get verification parameters for SIG_ALL proofs, accounting for locktime and refund keys +/// Returns (pubkeys, required_sigs) where required_sigs=0 means "anyone can spend" +/// Used by both SwapRequest and MeltRequest for SIG_ALL verification +fn get_sig_all_required_sigs(inputs: &Proofs) -> Result<(Vec, u64), Error> { + let first_input = inputs.first().ok_or(Error::SpendConditionsNotMet)?; + let first_conditions: SpendingConditions = + SpendingConditions::try_from(&first_input.secret)?; + + // Verify this is a P2PK proof with SigAll + let required_sigs = match first_conditions.clone() { + SpendingConditions::P2PKConditions { conditions, .. } => { + let conditions = conditions.ok_or(Error::IncorrectSecretKind)?; + + if SigFlag::SigAll != conditions.sig_flag { + return Err(Error::IncorrectSecretKind); + } + + conditions.num_sigs.unwrap_or(1) + } + _ => return Err(Error::IncorrectSecretKind), + }; + + // Check locktime to determine which keys to verify against + let now = unix_time(); + let locktime_passed = first_conditions + .locktime() + .map(|lt| now >= lt) + .unwrap_or(false); + + // Determine verification keys and required signature count based on locktime + if locktime_passed { + // Locktime has passed - check for refund keys + if let Some(refund_keys) = first_conditions.refund_keys() { + // Use refund keys and refund signature requirement + let refund_sigs_required = match &first_conditions { + SpendingConditions::P2PKConditions { conditions, .. } => { + conditions.as_ref() + .and_then(|c| c.num_sigs_refund) + .unwrap_or(1) + } + _ => 1, + }; + Ok((refund_keys, refund_sigs_required)) + } else { + // Locktime passed but no refund keys - anyone can spend (0 sigs required) + Ok((vec![], 0)) + } + } else { + // Before locktime - use normal pubkeys + let pubkeys = first_conditions + .pubkeys() + .ok_or(Error::P2PKPubkeyRequired)?; + Ok((pubkeys, required_sigs)) + } +} + impl SwapRequest { /// Generate the message to sign for SIG_ALL validation /// Concatenates all input secrets and output blinded messages in order @@ -896,27 +937,6 @@ impl SwapRequest { msg_to_sign } - /// Get required signature count from first input's spending conditions - fn get_sig_all_required_sigs(&self) -> Result<(u64, SpendingConditions), Error> { - let first_input = self.inputs().first().ok_or(Error::SpendConditionsNotMet)?; - let first_conditions: SpendingConditions = - SpendingConditions::try_from(&first_input.secret)?; - - let required_sigs = match first_conditions.clone() { - SpendingConditions::P2PKConditions { conditions, .. } => { - let conditions = conditions.ok_or(Error::IncorrectSecretKind)?; - - if SigFlag::SigAll != conditions.sig_flag { - return Err(Error::IncorrectSecretKind); - } - - conditions.num_sigs.unwrap_or(1) - } - _ => return Err(Error::IncorrectSecretKind), - }; - - Ok((required_sigs, first_conditions)) - } /// Verify all inputs have matching secrets and tags fn verify_matching_conditions(&self) -> Result<(), Error> { @@ -1028,25 +1048,29 @@ impl SwapRequest { /// Validate SIG_ALL conditions and signatures for the swap request pub fn verify_sig_all(&self) -> Result<(), Error> { - // Get required signatures and conditions from first input - let (required_sigs, first_conditions) = self.get_sig_all_required_sigs()?; - // Verify all inputs have matching secrets + // After this call, we know ALL proofs have identical conditions (locktime, pubkeys, refund_keys, etc.) self.verify_matching_conditions()?; + // Get verification parameters (accounts for locktime and refund keys) + // These are "relevant" because they depend on locktime: + // - Before locktime: normal pubkeys + num_sigs + // - After locktime with refund_keys: refund_keys + num_sigs_refund + // - After locktime without refund_keys: empty vec + 0 (anyone can spend) + let (relevant_pubkeys, relevant_num_sigs_required) = get_sig_all_required_sigs(self.inputs())?; + + if relevant_num_sigs_required == 0 { + return Ok(()); + } + // Get and validate witness signatures let signatures = self.get_valid_witness_signatures()?; - // Get signing pubkeys - let verifying_pubkeys = first_conditions - .pubkeys() - .ok_or(Error::P2PKPubkeyRequired)?; - // Get aggregated message and validate signatures let msg = self.sig_all_msg_to_sign(); - let valid_sigs = valid_signatures(msg.as_bytes(), &verifying_pubkeys, &signatures)?; + let valid_sigs = valid_signatures(msg.as_bytes(), &relevant_pubkeys, &signatures)?; - if valid_sigs >= required_sigs { + if valid_sigs >= relevant_num_sigs_required { Ok(()) } else { Err(Error::SpendConditionsNotMet) @@ -1079,27 +1103,6 @@ impl MeltRequest { msg_to_sign } - /// Get required signature count from first input's spending conditions - fn get_sig_all_required_sigs(&self) -> Result<(u64, SpendingConditions), Error> { - let first_input = self.inputs().first().ok_or(Error::SpendConditionsNotMet)?; - let first_conditions: SpendingConditions = - SpendingConditions::try_from(&first_input.secret)?; - - let required_sigs = match first_conditions.clone() { - SpendingConditions::P2PKConditions { conditions, .. } => { - let conditions = conditions.ok_or(Error::IncorrectSecretKind)?; - - if SigFlag::SigAll != conditions.sig_flag { - return Err(Error::IncorrectSecretKind); - } - - conditions.num_sigs.unwrap_or(1) - } - _ => return Err(Error::IncorrectSecretKind), - }; - - Ok((required_sigs, first_conditions)) - } /// Verify all inputs have matching secrets and tags fn verify_matching_conditions(&self) -> Result<(), Error> { @@ -1211,25 +1214,29 @@ impl MeltRequest { /// Validate SIG_ALL conditions and signatures for the melt request pub fn verify_sig_all(&self) -> Result<(), Error> { - // Get required signatures and conditions from first input - let (required_sigs, first_conditions) = self.get_sig_all_required_sigs()?; - // Verify all inputs have matching secrets + // After this call, we know ALL proofs have identical conditions (locktime, pubkeys, refund_keys, etc.) self.verify_matching_conditions()?; + // Get verification parameters (accounts for locktime and refund keys) + // These are "relevant" because they depend on locktime: + // - Before locktime: normal pubkeys + num_sigs + // - After locktime with refund_keys: refund_keys + num_sigs_refund + // - After locktime without refund_keys: empty vec + 0 (anyone can spend) + let (relevant_pubkeys, relevant_num_sigs_required) = get_sig_all_required_sigs(self.inputs())?; + + if relevant_num_sigs_required == 0 { + return Ok(()); + } + // Get and validate witness signatures let signatures = self.get_valid_witness_signatures()?; - // Get signing pubkeys - let verifying_pubkeys = first_conditions - .pubkeys() - .ok_or(Error::P2PKPubkeyRequired)?; - // Get aggregated message and validate signatures let msg = self.sig_all_msg_to_sign(); - let valid_sigs = valid_signatures(msg.as_bytes(), &verifying_pubkeys, &signatures)?; + let valid_sigs = valid_signatures(msg.as_bytes(), &relevant_pubkeys, &signatures)?; - if valid_sigs >= required_sigs { + if valid_sigs >= relevant_num_sigs_required { Ok(()) } else { Err(Error::SpendConditionsNotMet) @@ -1357,7 +1364,7 @@ mod tests { proof.sign_p2pk(secret_key).unwrap(); proof.sign_p2pk(signing_key_two).unwrap(); - assert!(proof.verify_p2pk().is_ok()); + assert!(proof.verify_p2pk_for_sig_input().is_ok()); } #[test] @@ -1372,15 +1379,15 @@ mod tests { }"#; let valid_proof: Proof = serde_json::from_str(json).unwrap(); - valid_proof.verify_p2pk().unwrap(); - assert!(valid_proof.verify_p2pk().is_ok()); + valid_proof.verify_p2pk_for_sig_input().unwrap(); + assert!(valid_proof.verify_p2pk_for_sig_input().is_ok()); // Proof with a signature that is in a different secret let invalid_proof = r#"{"amount":1,"secret":"[\"P2PK\",{\"nonce\":\"859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f\",\"data\":\"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7\",\"tags\":[[\"sigflag\",\"SIG_INPUTS\"]]}]","C":"02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904","id":"009a1f293253e41e","witness":"{\"signatures\":[\"3426df9730d365a9d18d79bed2f3e78e9172d7107c55306ac5ddd1b2d065893366cfa24ff3c874ebf1fc22360ba5888ddf6ff5dbcb9e5f2f5a1368f7afc64f15\"]}"}"#; let invalid_proof: Proof = serde_json::from_str(invalid_proof).unwrap(); - assert!(invalid_proof.verify_p2pk().is_err()); + assert!(invalid_proof.verify_p2pk_for_sig_input().is_err()); } #[test] @@ -1390,7 +1397,7 @@ mod tests { let valid_proof: Proof = serde_json::from_str(valid_proof).unwrap(); - assert!(valid_proof.verify_p2pk().is_ok()); + assert!(valid_proof.verify_p2pk_for_sig_input().is_ok()); // Proof with only one of the required signatures let invalid_proof = r#"{"amount":0,"secret":"[\"P2PK\",{\"nonce\":\"0ed3fcb22c649dd7bbbdcca36e0c52d4f0187dd3b6a19efcc2bfbebb5f85b2a1\",\"data\":\"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7\",\"tags\":[[\"pubkeys\",\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"02142715675faf8da1ecc4d51e0b9e539fa0d52fdd96ed60dbe99adb15d6b05ad9\"],[\"n_sigs\",\"2\"],[\"sigflag\",\"SIG_INPUTS\"]]}]","C":"02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904","id":"009a1f293253e41e","witness":"{\"signatures\":[\"83564aca48c668f50d022a426ce0ed19d3a9bdcffeeaee0dc1e7ea7e98e9eff1840fcc821724f623468c94f72a8b0a7280fa9ef5a54a1b130ef3055217f467b3\"]}"}"#; @@ -1398,7 +1405,7 @@ mod tests { let invalid_proof: Proof = serde_json::from_str(invalid_proof).unwrap(); // Verification should fail without the requires signatures - assert!(invalid_proof.verify_p2pk().is_err()); + assert!(invalid_proof.verify_p2pk_for_sig_input().is_err()); } #[test] @@ -1406,13 +1413,13 @@ mod tests { let valid_proof = r#"{"amount":1,"id":"009a1f293253e41e","secret":"[\"P2PK\",{\"nonce\":\"902685f492ef3bb2ca35a47ddbba484a3365d143b9776d453947dcbf1ddf9689\",\"data\":\"026f6a2b1d709dbca78124a9f30a742985f7eddd894e72f637f7085bf69b997b9a\",\"tags\":[[\"pubkeys\",\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"03142715675faf8da1ecc4d51e0b9e539fa0d52fdd96ed60dbe99adb15d6b05ad9\"],[\"locktime\",\"21\"],[\"n_sigs\",\"2\"],[\"refund\",\"026f6a2b1d709dbca78124a9f30a742985f7eddd894e72f637f7085bf69b997b9a\"],[\"sigflag\",\"SIG_INPUTS\"]]}]","C":"02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904","witness":"{\"signatures\":[\"710507b4bc202355c91ea3c147c0d0189c75e179d995e566336afd759cb342bcad9a593345f559d9b9e108ac2c9b5bd9f0b4b6a295028a98606a0a2e95eb54f7\"]}"}"#; let valid_proof: Proof = serde_json::from_str(valid_proof).unwrap(); - assert!(valid_proof.verify_p2pk().is_ok()); + assert!(valid_proof.verify_p2pk_for_sig_input().is_ok()); let invalid_proof = r#"{"amount":1,"id":"009a1f293253e41e","secret":"[\"P2PK\",{\"nonce\":\"64c46e5d30df27286166814b71b5d69801704f23a7ad626b05688fbdb48dcc98\",\"data\":\"026f6a2b1d709dbca78124a9f30a742985f7eddd894e72f637f7085bf69b997b9a\",\"tags\":[[\"pubkeys\",\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"03142715675faf8da1ecc4d51e0b9e539fa0d52fdd96ed60dbe99adb15d6b05ad9\"],[\"locktime\",\"21\"],[\"n_sigs\",\"2\"],[\"refund\",\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\"],[\"sigflag\",\"SIG_INPUTS\"]]}]","C":"02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904","witness":"{\"signatures\":[\"f661d3dc046d636d47cb3d06586da42c498f0300373d1c2a4f417a44252cdf3809bce207c8888f934dba0d2b1671f1b8622d526840f2d5883e571b462630c1ff\"]}"}"#; let invalid_proof: Proof = serde_json::from_str(invalid_proof).unwrap(); - assert!(invalid_proof.verify_p2pk().is_err()); + assert!(invalid_proof.verify_p2pk_for_sig_input().is_err()); } #[test] @@ -1459,15 +1466,15 @@ mod tests { proof.sign_p2pk(signing_key_three.clone()).unwrap(); - assert!(proof.verify_p2pk().is_err()); + assert!(proof.verify_p2pk_for_sig_input().is_err()); proof.witness = None; proof.sign_p2pk(secret_key).unwrap(); - assert!(proof.verify_p2pk().is_err()); + assert!(proof.verify_p2pk_for_sig_input().is_err()); proof.sign_p2pk(signing_key_two).unwrap(); - assert!(proof.verify_p2pk().is_ok()); + assert!(proof.verify_p2pk_for_sig_input().is_ok()); } // Helper functions for melt request tests diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs index 166e554f3..ab7a837de 100644 --- a/crates/cdk/src/mint/melt.rs +++ b/crates/cdk/src/mint/melt.rs @@ -26,8 +26,7 @@ use crate::amount::to_unit; use crate::cdk_payment::MakePaymentResponse; use crate::mint::proof_writer::ProofWriter; use crate::mint::verification::Verification; -use crate::mint::SigFlag; -use crate::nuts::nut11::{enforce_sig_flag, EnforceSigFlag}; +use crate::nuts::nut11::has_at_least_one_sig_all_proof; use crate::nuts::MeltQuoteState; use crate::types::PaymentProcessorKey; use crate::util::unix_time; @@ -562,9 +561,13 @@ impl Mint { )); } - let EnforceSigFlag { sig_flag, .. } = enforce_sig_flag(melt_request.inputs().clone()); - - if sig_flag == SigFlag::SigAll { + // For SIG_ALL: Verify signatures at the transaction level (all inputs + all outputs). + // This is called AFTER verify_inputs(), which skips individual proof verification + // for SigAll proofs (see verify_proofs in mint/mod.rs). + // + // For SigInputs (default): No transaction-level verification needed, as each proof + // was already verified individually in verify_inputs() -> verify_proofs(). + if has_at_least_one_sig_all_proof(melt_request.inputs().clone()) { melt_request.verify_sig_all()?; } diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index fbe7d6899..3d7962694 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -849,7 +849,20 @@ impl Mint { // that point. match secret.kind() { Kind::P2PK => { - proof.verify_p2pk()?; + // Check if this proof uses SigAll + let conditions: nuts::nut11::Conditions = secret + .secret_data() + .tags() + .cloned() + .unwrap_or_default() + .try_into()?; + + if conditions.sig_flag == nuts::nut11::SigFlag::SigAll { + // For SigAll, individual proof verification is skipped. + // Transaction-level verification happens in validate_sig_flag() + } else { + proof.verify_p2pk_for_sig_input()?; + } } Kind::HTLC => { proof.verify_htlc()?; @@ -1221,4 +1234,261 @@ mod tests { mint.start().await.expect("Should be able to restart"); mint.stop().await.expect("Final stop should work"); } + + #[tokio::test] + async fn test_swap_with_sig_all() { + // Initialize tracing for this test + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_test_writer() + .try_init(); + + use crate::dhke::{blind_message, construct_proofs}; + use crate::nuts::nut11::{Conditions, SigFlag}; + use crate::nuts::{BlindedMessage, SecretKey, SpendingConditions, SwapRequest}; + use crate::secret::Secret; + use crate::Amount; + + // Setup mint + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::Msat, (0, 32)); + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + let mint = create_mint(config).await; + + // Get active keyset + let keysets = mint.keysets(); + let active_keyset = keysets + .keysets + .iter() + .find(|k| k.active && k.unit == CurrencyUnit::Msat) + .expect("No active keyset"); + let active_keyset_id = active_keyset.id; + let mint_keys = mint.pubkeys().keysets[0].keys.clone(); + + // Generate keypairs for Alice and Bob + let alice_secret_key = SecretKey::generate(); + let alice_pubkey = alice_secret_key.public_key(); + let bob_secret_key = SecretKey::generate(); + let bob_pubkey = bob_secret_key.public_key(); + + // Create spending conditions: 2-of-2 multisig with SigAll + let conditions = Conditions::new( + None, // No locktime + Some(vec![bob_pubkey]), // Bob as additional pubkey + None, // No refund keys + Some(2), // Require 2 signatures + Some(SigFlag::SigAll), // Use SigAll + None, // No refund sig requirement + ) + .unwrap(); + + let spending_conditions = + SpendingConditions::new_p2pk(alice_pubkey, Some(conditions.clone())); + + // Create a blinded message with SigAll spending conditions (1 msat proof) + let nut10_secret: nuts::nut10::Secret = spending_conditions.clone().into(); + let secret: Secret = nut10_secret.try_into().unwrap(); + let (blinded_point, blinding_factor) = blind_message(&secret.to_bytes(), None).unwrap(); + + let blinded_msg = + BlindedMessage::new(Amount::from(1), active_keyset_id, blinded_point); + + // Mint signs the blinded message + let promises = mint.blind_sign(vec![blinded_msg.clone()]).await.unwrap(); + + // Construct the proof from the blind signature + let proofs = construct_proofs( + promises.clone(), + vec![blinding_factor.clone()], + vec![secret.clone()], + &mint_keys, + ) + .unwrap(); + + assert_eq!(proofs.len(), 1); + + // Create output for swap (1 msat, no spending conditions) + let output_secret = Secret::generate(); + let (output_blinded, output_r) = blind_message(&output_secret.to_bytes(), None).unwrap(); + let output_blinded_msg = + BlindedMessage::new(Amount::from(1), active_keyset_id, output_blinded); + + // Create SwapRequest with SigAll proofs + let mut swap_request = SwapRequest::new(proofs.clone(), vec![output_blinded_msg.clone()]); + + // Verify that unsigned request fails + assert!( + swap_request.verify_sig_all().is_err(), + "Unsigned swap request should fail verification" + ); + + // Sign with Alice only (should still fail - needs 2 sigs) + swap_request.sign_sig_all(alice_secret_key).unwrap(); + assert!( + swap_request.verify_sig_all().is_err(), + "Swap with only 1 signature should fail (needs 2)" + ); + + // Sign with Bob (now should pass verification) + swap_request.sign_sig_all(bob_secret_key).unwrap(); + assert!( + swap_request.verify_sig_all().is_ok(), + "Swap with both signatures should pass verification" + ); + + // Process the swap request through the mint + // This would have failed before the fix because verify_proofs() tried to verify + // SigAll signatures against individual proof secrets instead of skipping them + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Swap with SigAll should succeed"); + + assert_eq!(swap_response.signatures.len(), 1); + + // Verify we can construct the output proof + let output_proofs = construct_proofs( + swap_response.signatures, + vec![output_r], + vec![output_secret], + &mint_keys, + ) + .unwrap(); + + assert_eq!(output_proofs.len(), 1); + assert_eq!(output_proofs[0].amount, Amount::from(1)); + } + + #[tokio::test] + async fn test_melt_with_sig_all() { + use cdk_common::mint::MeltPaymentRequest; + use cdk_common::nuts::MeltQuoteState; + use crate::dhke::{blind_message, construct_proofs}; + use crate::nuts::nut11::{Conditions, SigFlag}; + use crate::nuts::{BlindedMessage, MeltRequest, SecretKey, SpendingConditions}; + use crate::secret::Secret; + use crate::util::unix_time; + use crate::Amount; + + // Setup mint with a melt quote + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::Msat, (0, 32)); + + let quote_id = QuoteId::new_uuid(); + let melt_quote = MeltQuote { + id: quote_id.clone(), + unit: CurrencyUnit::Msat, + amount: Amount::from(1), + request: MeltPaymentRequest::Bolt11 { + bolt11: "lnbc100n1p5z3a63pp56854ytysg7e5z9fl3w5mgvrlqjfcytnjv8ff5hm5qt6gl6alxesqdqqcqzzsxqyz5vqsp5p0x0dlhn27s63j4emxnk26p7f94u0lyarnfp5yqmac9gzy4ngdss9qxpqysgqne3v0hnzt2lp0hc69xpzckk0cdcar7glvjhq60lsrfe8gejdm8c564prrnsft6ctxxyrewp4jtezrq3gxxqnfjj0f9tw2qs9y0lslmqpfu7et9".parse().unwrap(), + }, + fee_reserve: Amount::ZERO, + state: MeltQuoteState::Unpaid, + expiry: unix_time() + 3600, + payment_preimage: None, + request_lookup_id: None, + options: None, + created_time: unix_time(), + paid_time: None, + payment_method: cdk_common::PaymentMethod::Bolt11, + }; + + let config = MintConfig::<'_> { + supported_units, + melt_quotes: vec![melt_quote], + ..Default::default() + }; + let mint = create_mint(config).await; + + // Get active keyset + let keysets = mint.keysets(); + let active_keyset = keysets + .keysets + .iter() + .find(|k| k.active && k.unit == CurrencyUnit::Msat) + .expect("No active keyset"); + let active_keyset_id = active_keyset.id; + let mint_keys = mint.pubkeys().keysets[0].keys.clone(); + + // Generate keypairs for Alice and Bob + let alice_secret_key = SecretKey::generate(); + let alice_pubkey = alice_secret_key.public_key(); + let bob_secret_key = SecretKey::generate(); + let bob_pubkey = bob_secret_key.public_key(); + + // Create spending conditions: 2-of-2 multisig with SigAll + let conditions = Conditions::new( + None, // No locktime + Some(vec![bob_pubkey]), // Bob as additional pubkey + None, // No refund keys + Some(2), // Require 2 signatures + Some(SigFlag::SigAll), // Use SigAll + None, // No refund sig requirement + ) + .unwrap(); + + let spending_conditions = + SpendingConditions::new_p2pk(alice_pubkey, Some(conditions.clone())); + + // Create a blinded message with SigAll spending conditions (1 msat proof) + let nut10_secret: nuts::nut10::Secret = spending_conditions.clone().into(); + let secret: Secret = nut10_secret.try_into().unwrap(); + let (blinded_point, blinding_factor) = blind_message(&secret.to_bytes(), None).unwrap(); + + let blinded_msg = + BlindedMessage::new(Amount::from(1), active_keyset_id, blinded_point); + + // Mint signs the blinded message + let promises = mint.blind_sign(vec![blinded_msg.clone()]).await.unwrap(); + + // Construct the proof from the blind signature + let proofs = construct_proofs( + promises.clone(), + vec![blinding_factor.clone()], + vec![secret.clone()], + &mint_keys, + ) + .unwrap(); + + assert_eq!(proofs.len(), 1); + + // Create MeltRequest with SigAll proofs (no outputs for simplicity) + let mut melt_request = MeltRequest::new(quote_id, proofs.clone(), None); + + // Verify that unsigned request fails + assert!( + melt_request.verify_sig_all().is_err(), + "Unsigned melt request should fail verification" + ); + + // Sign with Alice only (should still fail - needs 2 sigs) + melt_request.sign_sig_all(alice_secret_key).unwrap(); + assert!( + melt_request.verify_sig_all().is_err(), + "Melt with only 1 signature should fail (needs 2)" + ); + + // Sign with Bob (now should pass verification) + melt_request.sign_sig_all(bob_secret_key).unwrap(); + assert!( + melt_request.verify_sig_all().is_ok(), + "Melt with both signatures should pass verification" + ); + + // Verify inputs pass (this would have failed before the fix) + // This is the key part: verify_inputs() calls verify_proofs() which now skips + // individual verification for SigAll and lets verify_sig_all() handle it + let verification = mint.verify_inputs(&melt_request.inputs()).await; + assert!( + verification.is_ok(), + "Melt request inputs with SigAll should verify successfully" + ); + + let input_verification = verification.unwrap(); + assert_eq!(input_verification.amount, Amount::from(1)); + assert_eq!(input_verification.unit, Some(CurrencyUnit::Msat)); + } } diff --git a/crates/cdk/src/mint/swap.rs b/crates/cdk/src/mint/swap.rs index 8b3cc3711..ae1266c27 100644 --- a/crates/cdk/src/mint/swap.rs +++ b/crates/cdk/src/mint/swap.rs @@ -3,9 +3,9 @@ use cdk_prometheus::METRICS; use tracing::instrument; use super::blinded_message_writer::BlindedMessageWriter; -use super::nut11::{enforce_sig_flag, EnforceSigFlag}; +use super::nut11::has_at_least_one_sig_all_proof; use super::proof_writer::ProofWriter; -use super::{Mint, PublicKey, SigFlag, State, SwapRequest, SwapResponse}; +use super::{Mint, PublicKey, State, SwapRequest, SwapResponse}; use crate::Error; impl Mint { @@ -156,9 +156,13 @@ impl Mint { } async fn validate_sig_flag(&self, swap_request: &SwapRequest) -> Result<(), Error> { - let EnforceSigFlag { sig_flag, .. } = enforce_sig_flag(swap_request.inputs().clone()); - - if sig_flag == SigFlag::SigAll { + // For SIG_ALL: Verify signatures at the transaction level (all inputs + all outputs). + // This is called AFTER verify_inputs(), which skips individual proof verification + // for SigAll proofs (see verify_proofs in mint/mod.rs). + // + // For SigInputs (default): No transaction-level verification needed, as each proof + // was already verified individually in verify_inputs() -> verify_proofs(). + if has_at_least_one_sig_all_proof(swap_request.inputs().clone()) { swap_request.verify_sig_all()?; }