Bläddra i källkod

fix: htlc and p2pk conditions with spec update (#1435)

tsk 1 månad sedan
förälder
incheckning
6455b585ad

+ 232 - 132
crates/cashu/src/nuts/nut10.rs

@@ -13,18 +13,30 @@ use thiserror::Error;
 use super::nut01::PublicKey;
 use super::Conditions;
 
+/// Refund path requirements (available after locktime for HTLC)
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct RefundPath {
+    /// Public keys that can provide valid signatures for refund
+    pub pubkeys: Vec<PublicKey>,
+    /// Minimum number of signatures required from the refund pubkeys
+    pub required_sigs: u64,
+}
+
 /// Spending requirements for P2PK or HTLC verification
 ///
 /// Returned by `get_pubkeys_and_required_sigs` to indicate what conditions
 /// must be met to spend a proof.
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub(crate) struct SpendingRequirements {
-    /// Whether a preimage is required (HTLC only, before locktime)
+    /// Whether a preimage is required (HTLC only, for receiver path)
     pub preimage_needed: bool,
-    /// Public keys that can provide valid signatures
+    /// Public keys that can provide valid signatures (receiver path)
     pub pubkeys: Vec<PublicKey>,
     /// Minimum number of signatures required from the pubkeys
     pub required_sigs: u64,
+    /// Refund path (available after locktime for HTLC)
+    /// Per NUT-14: receiver path is ALWAYS available, refund path is available after locktime
+    pub refund_path: Option<RefundPath>,
 }
 
 /// NUT13 Error
@@ -125,18 +137,23 @@ impl Secret {
 /// Get the relevant public keys and required signature count for P2PK or HTLC verification
 /// This is for NUT-11(P2PK) and NUT-14(HTLC)
 ///
-/// Takes into account locktime - if locktime has passed, returns refund keys,
-/// otherwise returns primary pubkeys/hash path.
-/// From NUT-11: "If the tag locktime is the unix time and the mint's local clock is greater than
-/// locktime, the Proof becomes spendable by anyone, except [... if refund keys are specified]"
+/// For P2PK (NUT-11):
+/// - Before locktime: only primary pubkeys path available
+/// - After locktime with refund keys: refund path available
+/// - After locktime without refund keys: anyone can spend
 ///
-/// Returns `SpendingRequirements` containing:
-/// - `preimage_needed`: For P2PK, always false. For HTLC, true before locktime.
-/// - `pubkeys`: The public keys that can provide valid signatures
-/// - `required_sigs`: The minimum number of signatures required
+/// For HTLC (NUT-14):
+/// - Receiver path (preimage + pubkeys): ALWAYS available
+/// - Sender/Refund path (refund keys, no preimage): available AFTER locktime
+///
+/// From NUT-14: "This pathway is ALWAYS available to the receivers, as possession
+/// of the preimage confirms performance of the Sender's wishes."
 ///
-/// From NUT-14: "if the current system time is later than Secret.tag.locktime, the Proof can
-/// be spent if Proof.witness includes a signature from the key in Secret.tags.refund."
+/// Returns `SpendingRequirements` containing:
+/// - `preimage_needed`: For P2PK, always false. For HTLC, true (receiver path).
+/// - `pubkeys`: The public keys for the primary/receiver path
+/// - `required_sigs`: The minimum number of signatures required for primary path
+/// - `refund_path`: Optional refund path (available after locktime)
 pub(crate) fn get_pubkeys_and_required_sigs(
     secret: &Secret,
     current_time: u64,
@@ -159,65 +176,86 @@ pub(crate) fn get_pubkeys_and_required_sigs(
         .map(|locktime| locktime < current_time)
         .unwrap_or(false);
 
-    // Determine which keys and signature count to use
-    if locktime_passed {
-        // After locktime: use refund path (no preimage needed)
-        if let Some(refund_keys) = &conditions.refund_keys {
-            // Locktime has passed and refund keys exist - use refund keys
-            let refund_sigs = conditions.num_sigs_refund.unwrap_or(1);
-            Ok(SpendingRequirements {
-                preimage_needed: false,
-                pubkeys: refund_keys.clone(),
-                required_sigs: refund_sigs,
-            })
-        } else {
-            // Locktime has passed with no refund keys - anyone can spend
+    match secret.kind() {
+        Kind::P2PK => {
+            // P2PK: never needs preimage
+            // Per NUT-11: "Locktime Multisig conditions continue to apply, and the proof
+            // can continue to be spent according to Locktime Multisig rules."
+            // This means the primary path (data + pubkeys) is ALWAYS available.
+
+            // Build primary pubkeys (data + pubkeys tag)
+            let mut primary_keys = vec![];
+
+            // Add the pubkey from secret.data
+            let data_pubkey = PublicKey::from_str(secret.secret_data().data())?;
+            primary_keys.push(data_pubkey);
+
+            // Add any additional pubkeys from conditions
+            if let Some(additional_keys) = &conditions.pubkeys {
+                primary_keys.extend(additional_keys.clone());
+            }
+
+            let primary_num_sigs_required = conditions.num_sigs.unwrap_or(1);
+
+            // Refund path is available after locktime
+            let refund_path = if locktime_passed {
+                if let Some(refund_keys) = &conditions.refund_keys {
+                    Some(RefundPath {
+                        pubkeys: refund_keys.clone(),
+                        required_sigs: conditions.num_sigs_refund.unwrap_or(1),
+                    })
+                } else {
+                    // Locktime passed, no refund keys: anyone can spend via refund path
+                    Some(RefundPath {
+                        pubkeys: vec![],
+                        required_sigs: 0,
+                    })
+                }
+            } else {
+                None
+            };
+
             Ok(SpendingRequirements {
                 preimage_needed: false,
-                pubkeys: vec![],
-                required_sigs: 0,
+                pubkeys: primary_keys,
+                required_sigs: primary_num_sigs_required,
+                refund_path,
             })
         }
-    } else {
-        // Before locktime: logic differs between P2PK and HTLC
-        match secret.kind() {
-            Kind::P2PK => {
-                // P2PK: never needs preimage, use primary pubkeys
-                let mut primary_keys = vec![];
-
-                // Add the pubkey from secret.data
-                let data_pubkey = PublicKey::from_str(secret.secret_data().data())?;
-                primary_keys.push(data_pubkey);
+        Kind::HTLC => {
+            // HTLC: receiver path (preimage + pubkeys) is ALWAYS available per NUT-14
+            // "This pathway is ALWAYS available to the receivers"
+            let pubkeys = conditions.pubkeys.clone().unwrap_or_default();
+            let required_sigs = if pubkeys.is_empty() {
+                0
+            } else {
+                conditions.num_sigs.unwrap_or(1)
+            };
 
-                // Add any additional pubkeys from conditions
-                if let Some(additional_keys) = &conditions.pubkeys {
-                    primary_keys.extend(additional_keys.clone());
+            // Refund path is available after locktime
+            let refund_path = if locktime_passed {
+                if let Some(refund_keys) = &conditions.refund_keys {
+                    Some(RefundPath {
+                        pubkeys: refund_keys.clone(),
+                        required_sigs: conditions.num_sigs_refund.unwrap_or(1),
+                    })
+                } else {
+                    // Locktime passed, no refund keys: anyone can spend via refund path
+                    Some(RefundPath {
+                        pubkeys: vec![],
+                        required_sigs: 0,
+                    })
                 }
+            } else {
+                None
+            };
 
-                let primary_num_sigs_required = conditions.num_sigs.unwrap_or(1);
-                Ok(SpendingRequirements {
-                    preimage_needed: false,
-                    pubkeys: primary_keys,
-                    required_sigs: primary_num_sigs_required,
-                })
-            }
-            Kind::HTLC => {
-                // HTLC: needs preimage before locktime, pubkeys from conditions
-                // (data contains hash, not pubkey)
-                let pubkeys = conditions.pubkeys.clone().unwrap_or_default();
-                // If no pubkeys are specified, require 0 signatures (only preimage needed)
-                // Otherwise, default to requiring 1 signature
-                let required_sigs = if pubkeys.is_empty() {
-                    0
-                } else {
-                    conditions.num_sigs.unwrap_or(1)
-                };
-                Ok(SpendingRequirements {
-                    preimage_needed: true,
-                    pubkeys,
-                    required_sigs,
-                })
-            }
+            Ok(SpendingRequirements {
+                preimage_needed: true,
+                pubkeys,
+                required_sigs,
+                refund_path,
+            })
         }
     }
 }
@@ -253,6 +291,26 @@ pub fn verify_htlc_preimage(
     Ok(())
 }
 
+/// Extract and parse Schnorr signatures from a witness
+///
+/// This helper function extracts signature strings from a witness and parses them
+/// into bitcoin secp256k1 Schnorr signatures.
+pub fn extract_signatures_from_witness(
+    witness: &super::Witness,
+) -> Result<Vec<bitcoin::secp256k1::schnorr::Signature>, super::nut11::Error> {
+    use std::str::FromStr;
+
+    let witness_sigs = witness
+        .signatures()
+        .ok_or(super::nut11::Error::SignaturesNotProvided)?;
+
+    witness_sigs
+        .iter()
+        .map(|s| bitcoin::secp256k1::schnorr::Signature::from_str(s))
+        .collect::<Result<Vec<_>, _>>()
+        .map_err(|_| super::nut11::Error::InvalidSignature)
+}
+
 /// Trait for requests that spend proofs (SwapRequest, MeltRequest)
 pub trait SpendingConditionVerification {
     /// Get the input proofs
@@ -440,6 +498,10 @@ pub trait SpendingConditionVerification {
     /// Do NOT call this directly. This is called only from 'verify_full_sig_all_check',
     /// which has already done many important SIG_ALL checks. This performs the final
     /// signature verification for SIG_ALL+P2PK transactions.
+    ///
+    /// Per NUT-11, there are two spending pathways after locktime:
+    /// 1. Primary path (data + pubkeys): ALWAYS available
+    /// 2. Refund path (refund keys): available AFTER locktime
     fn verify_sig_all_p2pk(&self) -> Result<(), super::nut11::Error> {
         // Get the first input, as it's the one with the signatures
         let first_input = self
@@ -452,7 +514,7 @@ pub trait SpendingConditionVerification {
         // Record current time for locktime evaluation
         let current_time = crate::util::unix_time();
 
-        // Get the relevant public keys and required signature count based on locktime
+        // Get spending requirements (includes both primary and refund paths)
         let requirements = get_pubkeys_and_required_sigs(&first_secret, current_time)?;
 
         debug_assert!(
@@ -460,45 +522,61 @@ pub trait SpendingConditionVerification {
             "P2PK should never require preimage"
         );
 
-        // Handle "anyone can spend" case (locktime passed with no refund keys)
-        if requirements.required_sigs == 0 {
-            return Ok(());
+        // Check for "anyone can spend" case first (locktime passed, no refund keys)
+        // This doesn't require any signatures
+        if let Some(refund_path) = &requirements.refund_path {
+            if refund_path.required_sigs == 0 {
+                return Ok(());
+            }
         }
 
         // Construct the message that should be signed
         let msg_to_sign = self.sig_all_msg_to_sign();
 
-        // Extract signatures from the first input's witness
+        // Get the witness (needed for signature extraction)
         let first_witness = first_input
             .witness
             .as_ref()
             .ok_or(super::nut11::Error::SignaturesNotProvided)?;
 
-        let witness_sigs = first_witness
-            .signatures()
-            .ok_or(super::nut11::Error::SignaturesNotProvided)?;
+        // Try primary path first (data + pubkeys)
+        // Per NUT-11: "Locktime Multisig conditions continue to apply"
+        {
+            let primary_valid = extract_signatures_from_witness(first_witness)
+                .ok()
+                .and_then(|sigs| {
+                    super::nut11::valid_signatures(
+                        msg_to_sign.as_bytes(),
+                        &requirements.pubkeys,
+                        &sigs,
+                    )
+                    .ok()
+                })
+                .is_some_and(|count| count >= requirements.required_sigs);
 
-        // Convert witness strings to Signature objects
-        use std::str::FromStr;
-        let signatures: Vec<bitcoin::secp256k1::schnorr::Signature> = witness_sigs
-            .iter()
-            .map(|s| bitcoin::secp256k1::schnorr::Signature::from_str(s))
-            .collect::<Result<Vec<_>, _>>()
-            .map_err(|_| super::nut11::Error::InvalidSignature)?;
-
-        // Verify signatures using the existing valid_signatures function
-        let valid_sig_count = super::nut11::valid_signatures(
-            msg_to_sign.as_bytes(),
-            &requirements.pubkeys,
-            &signatures,
-        )?;
-
-        // Check if we have enough valid signatures
-        if valid_sig_count < requirements.required_sigs {
-            return Err(super::nut11::Error::SpendConditionsNotMet);
+            if primary_valid {
+                return Ok(());
+            }
         }
 
-        Ok(())
+        // Primary path failed - try refund path if available
+        {
+            if let Some(refund_path) = &requirements.refund_path {
+                let signatures = extract_signatures_from_witness(first_witness)?;
+                let valid_sig_count = super::nut11::valid_signatures(
+                    msg_to_sign.as_bytes(),
+                    &refund_path.pubkeys,
+                    &signatures,
+                )?;
+
+                if valid_sig_count >= refund_path.required_sigs {
+                    return Ok(());
+                }
+            }
+        }
+
+        // Neither path succeeded
+        Err(super::nut11::Error::SpendConditionsNotMet)
     }
 
     /// Verify HTLC SIG_ALL signatures
@@ -506,6 +584,10 @@ pub trait SpendingConditionVerification {
     /// Do NOT call this directly. This is called only from 'verify_full_sig_all_check',
     /// which has already done many important SIG_ALL checks. This performs the final
     /// signature verification for SIG_ALL+HTLC transactions.
+    ///
+    /// Per NUT-14, there are two spending pathways:
+    /// 1. Receiver path (preimage + pubkeys): ALWAYS available
+    /// 2. Sender/Refund path (refund keys, no preimage): available AFTER locktime
     fn verify_sig_all_htlc(&self) -> Result<(), super::nut11::Error> {
         // Get the first input, as it's the one with the signatures
         let first_input = self
@@ -518,61 +600,79 @@ pub trait SpendingConditionVerification {
         // Record current time for locktime evaluation
         let current_time = crate::util::unix_time();
 
-        // Get the relevant public keys, required signature count, and whether preimage is needed
+        // Get the spending requirements (includes both receiver and refund paths)
         let requirements = get_pubkeys_and_required_sigs(&first_secret, current_time)?;
 
-        // If preimage is needed (before locktime), verify it
-        if requirements.preimage_needed {
-            // Extract HTLC witness
-            let htlc_witness = match first_input.witness.as_ref() {
-                Some(super::Witness::HTLCWitness(witness)) => witness,
-                _ => return Err(super::nut11::Error::SignaturesNotProvided),
-            };
-
-            // Verify the preimage matches the hash in the secret
-            verify_htlc_preimage(htlc_witness, &first_secret)
-                .map_err(|_| super::nut11::Error::SpendConditionsNotMet)?;
-        }
+        // Try to extract HTLC witness and check if preimage is valid
+        let htlc_witness = match first_input.witness.as_ref() {
+            Some(super::Witness::HTLCWitness(witness)) => Some(witness),
+            _ => None,
+        };
 
-        // Handle "anyone can spend" case (locktime passed with no refund keys)
-        if requirements.required_sigs == 0 {
-            return Ok(());
+        // Check if a valid preimage is provided
+        let preimage_valid = htlc_witness
+            .map(|w| verify_htlc_preimage(w, &first_secret).is_ok())
+            .unwrap_or(false);
+
+        // Check for "anyone can spend" case first (preimage invalid, locktime passed, no refund keys)
+        // This doesn't require any signatures
+        if !preimage_valid {
+            if let Some(refund_path) = &requirements.refund_path {
+                if refund_path.required_sigs == 0 {
+                    return Ok(());
+                }
+            }
         }
 
-        // Construct the message that should be signed
+        // Construct the message that should be signed (same for both paths)
         let msg_to_sign = self.sig_all_msg_to_sign();
 
-        // Extract signatures from the first input's witness
+        // Get the witness (needed for signature extraction)
         let first_witness = first_input
             .witness
             .as_ref()
             .ok_or(super::nut11::Error::SignaturesNotProvided)?;
 
-        let witness_sigs = first_witness
-            .signatures()
-            .ok_or(super::nut11::Error::SignaturesNotProvided)?;
+        // Determine which path to use:
+        // - If preimage is valid → use receiver path (always available)
+        // - If preimage is invalid/missing → try refund path (if available)
+        if preimage_valid {
+            // Receiver path: preimage valid, now check SIG_ALL signatures against pubkeys
+            if requirements.required_sigs == 0 {
+                return Ok(());
+            }
 
-        // Convert witness strings to Signature objects
-        use std::str::FromStr;
-        let signatures: Vec<bitcoin::secp256k1::schnorr::Signature> = witness_sigs
-            .iter()
-            .map(|s| bitcoin::secp256k1::schnorr::Signature::from_str(s))
-            .collect::<Result<Vec<_>, _>>()
-            .map_err(|_| super::nut11::Error::InvalidSignature)?;
-
-        // Verify signatures using the existing valid_signatures function
-        let valid_sig_count = super::nut11::valid_signatures(
-            msg_to_sign.as_bytes(),
-            &requirements.pubkeys,
-            &signatures,
-        )?;
-
-        // Check if we have enough valid signatures
-        if valid_sig_count < requirements.required_sigs {
-            return Err(super::nut11::Error::SpendConditionsNotMet);
+            let signatures = extract_signatures_from_witness(first_witness)?;
+            let valid_sig_count = super::nut11::valid_signatures(
+                msg_to_sign.as_bytes(),
+                &requirements.pubkeys,
+                &signatures,
+            )?;
+
+            if valid_sig_count >= requirements.required_sigs {
+                Ok(())
+            } else {
+                Err(super::nut11::Error::SpendConditionsNotMet)
+            }
+        } else if let Some(refund_path) = &requirements.refund_path {
+            // Refund path: preimage not valid/provided, but locktime has passed
+            // Check SIG_ALL signatures against refund keys
+            let signatures = extract_signatures_from_witness(first_witness)?;
+            let valid_sig_count = super::nut11::valid_signatures(
+                msg_to_sign.as_bytes(),
+                &refund_path.pubkeys,
+                &signatures,
+            )?;
+
+            if valid_sig_count >= refund_path.required_sigs {
+                Ok(())
+            } else {
+                Err(super::nut11::Error::SpendConditionsNotMet)
+            }
+        } else {
+            // No valid preimage and refund path not available (locktime not passed)
+            Err(super::nut11::Error::SpendConditionsNotMet)
         }
-
-        Ok(())
     }
 }
 

+ 70 - 24
crates/cashu/src/nuts/nut11/mod.rs

@@ -136,6 +136,12 @@ impl Proof {
     }
 
     /// Verify P2PK signature on [Proof]
+    ///
+    /// Per NUT-11, there are two spending pathways after locktime:
+    /// 1. Primary path (data + pubkeys): ALWAYS available
+    /// 2. Refund path (refund keys): available AFTER locktime
+    ///
+    /// The verification tries both paths - if either succeeds, the proof is valid.
     pub fn verify_p2pk(&self) -> Result<(), Error> {
         let secret: Nut10Secret = self.secret.clone().try_into()?;
         let spending_conditions: Conditions = secret
@@ -153,7 +159,7 @@ impl Proof {
             return Err(Error::IncorrectSecretKind);
         }
 
-        // Based on the current time, we must identify the relevant keys
+        // Get spending requirements (includes both primary and refund paths)
         let now = unix_time();
         let requirements = super::nut10::get_pubkeys_and_required_sigs(&secret, now)?;
 
@@ -161,32 +167,62 @@ impl Proof {
             return Err(Error::PreimageNotSupportedInP2PK);
         }
 
-        // Handle "anyone can spend" case (locktime passed with no refund keys)
-        if requirements.required_sigs == 0 {
-            return Ok(());
-        }
-
         // Extract witness signatures
         let witness_signatures = match &self.witness {
             Some(witness) => witness.signatures(),
             None => None,
         };
-        let witness_signatures = witness_signatures.ok_or(Error::SignaturesNotProvided)?;
 
-        // Count valid signatures using relevant_pubkeys
         let msg: &[u8] = self.secret.as_bytes();
-        let valid_sig_count = valid_signatures(
-            msg,
-            &requirements.pubkeys,
-            &witness_signatures
-                .iter()
-                .map(|s| Signature::from_str(s))
-                .collect::<Result<Vec<_>, _>>()?,
-        )?;
 
-        // Check if we have enough valid signatures
-        if valid_sig_count >= requirements.required_sigs {
-            Ok(())
+        // Try primary path first (data + pubkeys)
+        // Per NUT-11: "Locktime Multisig conditions continue to apply"
+        {
+            let primary_valid = witness_signatures
+                .as_ref()
+                .and_then(|sigs| {
+                    sigs.iter()
+                        .map(|s| Signature::from_str(s))
+                        .collect::<Result<Vec<_>, _>>()
+                        .ok()
+                })
+                .and_then(|sigs| valid_signatures(msg, &requirements.pubkeys, &sigs).ok())
+                .is_some_and(|count| count >= requirements.required_sigs);
+
+            if primary_valid {
+                return Ok(());
+            }
+        }
+
+        // Primary path failed or no signatures - try refund path if available
+        {
+            if let Some(refund_path) = &requirements.refund_path {
+                // Anyone can spend (locktime passed, no refund keys)
+                if refund_path.required_sigs == 0 {
+                    return Ok(());
+                }
+
+                // Need signatures for refund path
+                let refund_valid = witness_signatures
+                    .as_ref()
+                    .and_then(|sigs| {
+                        sigs.iter()
+                            .map(|s| Signature::from_str(s))
+                            .collect::<Result<Vec<_>, _>>()
+                            .ok()
+                    })
+                    .and_then(|sigs| valid_signatures(msg, &refund_path.pubkeys, &sigs).ok())
+                    .is_some_and(|count| count >= refund_path.required_sigs);
+
+                if refund_valid {
+                    return Ok(());
+                }
+            }
+        }
+
+        // Neither path succeeded
+        if witness_signatures.is_none() {
+            Err(Error::SignaturesNotProvided)
         } else {
             Err(Error::SpendConditionsNotMet)
         }
@@ -1138,14 +1174,22 @@ mod tests {
 
         proof.sign_p2pk(signing_key_three.clone()).unwrap();
 
-        assert!(proof.verify_p2pk().is_err());
+        // Per NUT-11: primary path (pubkeys) is ALWAYS available, even after locktime
+        // Signing with a key from pubkeys should succeed
+        assert!(proof.verify_p2pk().is_ok());
 
         proof.witness = None;
 
+        // Sign with secret_key (pubkey = v_key, which is the data key and part of primary path)
+        // Per NUT-11: primary path is always available, and data key is part of primary path
         proof.sign_p2pk(secret_key).unwrap();
-        assert!(proof.verify_p2pk().is_err());
-        proof.sign_p2pk(signing_key_two).unwrap();
+        assert!(
+            proof.verify_p2pk().is_ok(),
+            "Data key signature should satisfy primary path"
+        );
 
+        // Adding more signatures still works (but wasn't needed for primary path)
+        proof.sign_p2pk(signing_key_two).unwrap();
         assert!(proof.verify_p2pk().is_ok());
     }
 
@@ -1725,7 +1769,9 @@ mod tests {
 
     #[test]
     fn test_sig_all_htlc_post_locktime() {
-        // The following is a valid SwapRequest with a multisig HTLC also locked to locktime and refund keys.
+        // The following is a valid SwapRequest with a multisig HTLC using the refund path.
+        // Per NUT-14: After locktime, the refund path is available when preimage is invalid/missing.
+        // The preimage is intentionally invalid (all zeros) to test the refund path.
         let valid_swap = r#"{
               "inputs": [
                 {
@@ -1733,7 +1779,7 @@ mod tests {
                   "id": "00bfa73302d12ffd",
                   "secret": "[\"HTLC\",{\"nonce\":\"c9b0fabb8007c0db4bef64d5d128cdcf3c79e8bb780c3294adf4c88e96c32647\",\"data\":\"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5\",\"tags\":[[\"pubkeys\",\"039e6ec7e922abb4162235b3a42965eb11510b07b7461f6b1a17478b1c9c64d100\"],[\"locktime\",\"1\"],[\"refund\",\"02ce1bbd2c9a4be8029c9a6435ad601c45677f5cde81f8a7f0ed535e0039d0eb6c\",\"03c43c00ff57f63cfa9e732f0520c342123e21331d0121139f1b636921eeec095f\"],[\"n_sigs_refund\",\"2\"],[\"sigflag\",\"SIG_ALL\"]]}]",
                   "C": "0344b6f1471cf18a8cbae0e624018c816be5e3a9b04dcb7689f64173c1ae90a3a5",
-                  "witness": "{\"preimage\":\"0000000000000000000000000000000000000000000000000000000000000001\",\"signatures\":[\"98e21672d409cc782c720f203d8284f0af0c8713f18167499f9f101b7050c3e657fb0e57478ebd8bd561c31aa6c30f4cd20ec38c73f5755b7b4ddee693bca5a5\",\"693f40129dbf905ed9c8008081c694f72a36de354f9f4fa7a61b389cf781f62a0ae0586612fb2eb504faaf897fefb6742309186117f4743bcebcb8e350e975e2\"]}"
+                  "witness": "{\"preimage\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"signatures\":[\"98e21672d409cc782c720f203d8284f0af0c8713f18167499f9f101b7050c3e657fb0e57478ebd8bd561c31aa6c30f4cd20ec38c73f5755b7b4ddee693bca5a5\",\"693f40129dbf905ed9c8008081c694f72a36de354f9f4fa7a61b389cf781f62a0ae0586612fb2eb504faaf897fefb6742309186117f4743bcebcb8e350e975e2\"]}"
                 }
               ],
               "outputs": [

+ 97 - 66
crates/cashu/src/nuts/nut14/mod.rs

@@ -94,6 +94,13 @@ impl HTLCWitness {
 
 impl Proof {
     /// Verify HTLC
+    ///
+    /// Per NUT-14, there are two spending pathways:
+    /// 1. Receiver path (preimage + pubkeys): ALWAYS available
+    /// 2. Sender/Refund path (refund keys, no preimage): available AFTER locktime
+    ///
+    /// The verification tries to determine which path is being used based on
+    /// the witness provided, then validates accordingly.
     pub fn verify_htlc(&self) -> Result<(), Error> {
         let secret: Secret = self.secret.clone().try_into()?;
         let spending_conditions: Conditions = secret
@@ -111,65 +118,86 @@ impl Proof {
             return Err(Error::IncorrectSecretKind);
         }
 
-        // Get the appropriate spending conditions based on locktime
+        // Get the spending requirements (includes both receiver and refund paths)
         let now = unix_time();
         let requirements =
             super::nut10::get_pubkeys_and_required_sigs(&secret, now).map_err(Error::NUT11)?;
 
-        // While a Witness is usually needed in a P2PK or HTLC proof, it's not
-        // always needed. If we are past the locktime, and there are no refund
-        // keys, then the proofs are anyone-can-spend:
-        //     NUT-11: "If the tag locktime is the unix time and the mint's local
-        //              clock is greater than locktime, the Proof becomes spendable
-        //              by anyone, except if [there are no refund keys]"
-        // Therefore, this function should not extract any Witness unless it
-        // is needed to get a preimage or signatures.
-
-        // If preimage is needed (before locktime), verify it
-        if requirements.preimage_needed {
-            // Extract HTLC witness
-            let htlc_witness = match &self.witness {
-                Some(Witness::HTLCWitness(witness)) => witness,
-                _ => return Err(Error::IncorrectSecretKind),
-            };
-
-            // Verify preimage using shared function
-            super::nut10::verify_htlc_preimage(htlc_witness, &secret)?;
-        }
-
-        if requirements.required_sigs == 0 {
-            return Ok(());
-        }
-
-        // if we get here, the preimage check (if it was needed) has been done
-        // and we know that at least one signature is required. So, we extract
-        // the witness.signatures and count them:
-
-        // Extract witness signatures
+        // Try to extract HTLC witness - must be correct type
         let htlc_witness = match &self.witness {
             Some(Witness::HTLCWitness(witness)) => witness,
-            _ => return Err(Error::IncorrectSecretKind),
+            _ => {
+                // Wrong witness type or no witness
+                // If refund path is available with 0 required sigs, anyone can spend
+                if let Some(refund_path) = &requirements.refund_path {
+                    if refund_path.required_sigs == 0 {
+                        return Ok(());
+                    }
+                }
+                return Err(Error::IncorrectSecretKind);
+            }
         };
-        let witness_signatures = htlc_witness
-            .signatures
-            .as_ref()
-            .ok_or(Error::SignaturesNotProvided)?;
 
-        // Convert signatures from strings
-        let signatures: Vec<Signature> = witness_signatures
-            .iter()
-            .map(|s| Signature::from_str(s))
-            .collect::<Result<Vec<_>, _>>()?;
-
-        // Count valid signatures using relevant_pubkeys
-        let msg: &[u8] = self.secret.as_bytes();
-        let valid_sig_count = valid_signatures(msg, &requirements.pubkeys, &signatures)?;
-
-        // Check if we have enough valid signatures
-        if valid_sig_count >= requirements.required_sigs {
-            Ok(())
+        // Try to verify the preimage and capture the specific error if it fails
+        let preimage_result = super::nut10::verify_htlc_preimage(htlc_witness, &secret);
+
+        // Determine which path to use:
+        // - If preimage is valid → use receiver path (always available)
+        // - If preimage is invalid/missing → try refund path (if available)
+        if preimage_result.is_ok() {
+            // Receiver path: preimage valid, now check signatures against pubkeys
+            if requirements.required_sigs == 0 {
+                return Ok(());
+            }
+
+            let witness_signatures = htlc_witness
+                .signatures
+                .as_ref()
+                .ok_or(Error::SignaturesNotProvided)?;
+
+            let signatures: Vec<Signature> = witness_signatures
+                .iter()
+                .map(|s| Signature::from_str(s))
+                .collect::<Result<Vec<_>, _>>()?;
+
+            let msg: &[u8] = self.secret.as_bytes();
+            let valid_sig_count = valid_signatures(msg, &requirements.pubkeys, &signatures)?;
+
+            if valid_sig_count >= requirements.required_sigs {
+                Ok(())
+            } else {
+                Err(Error::NUT11(super::nut11::Error::SpendConditionsNotMet))
+            }
+        } else if let Some(refund_path) = &requirements.refund_path {
+            // Refund path: preimage not valid/provided, but locktime has passed
+            // Check signatures against refund keys
+            if refund_path.required_sigs == 0 {
+                // Anyone can spend (locktime passed, no refund keys)
+                return Ok(());
+            }
+
+            let witness_signatures = htlc_witness
+                .signatures
+                .as_ref()
+                .ok_or(Error::SignaturesNotProvided)?;
+
+            let signatures: Vec<Signature> = witness_signatures
+                .iter()
+                .map(|s| Signature::from_str(s))
+                .collect::<Result<Vec<_>, _>>()?;
+
+            let msg: &[u8] = self.secret.as_bytes();
+            let valid_sig_count = valid_signatures(msg, &refund_path.pubkeys, &signatures)?;
+
+            if valid_sig_count >= refund_path.required_sigs {
+                Ok(())
+            } else {
+                Err(Error::NUT11(super::nut11::Error::SpendConditionsNotMet))
+            }
         } else {
-            Err(Error::NUT11(super::nut11::Error::SpendConditionsNotMet))
+            // No valid preimage and refund path not available (locktime not passed)
+            // Return the specific error from preimage verification
+            preimage_result
         }
     }
 
@@ -411,25 +439,26 @@ mod tests {
 
     /// Tests that verify_htlc requires BOTH locktime expired AND no refund keys for "anyone can spend".
     ///
-    /// This test catches the mutation that replaces `&&` with `||` at line 83.
-    /// The logic should be: (locktime expired AND no refund keys) → anyone can spend.
-    /// If mutated to OR, it would allow spending when locktime passed even if refund keys exist.
+    /// This test verifies that when locktime has passed and refund keys are present,
+    /// a signature from the refund keys is required (not anyone-can-spend).
     ///
-    /// Mutant testing: Catches mutations that replace `&&` with `||` in the locktime check.
+    /// Per NUT-14: After locktime, the refund path requires signatures from refund keys.
+    /// The "anyone can spend" case only applies when locktime passed AND no refund keys.
     #[test]
     fn test_htlc_locktime_and_refund_keys_logic() {
         use crate::nuts::nut01::PublicKey;
         use crate::nuts::nut11::Conditions;
 
-        let preimage_bytes = [42u8; 32]; // 32-byte preimage
-        let hash = Sha256Hash::hash(&preimage_bytes);
+        let correct_preimage_bytes = [42u8; 32]; // 32-byte preimage
+        let hash = Sha256Hash::hash(&correct_preimage_bytes);
         let hash_str = hash.to_string();
 
+        // Use WRONG preimage to force using refund path (not receiver path)
+        let wrong_preimage_bytes = [99u8; 32];
+
         // Test: Locktime has passed (locktime=1) but refund keys ARE present
-        // With correct logic (&&): Since refund_keys.is_none() is false, the "anyone can spend"
-        //                          path is NOT taken, so signature is required
-        // With mutation (||): Since locktime.lt(&unix_time()) is true, it WOULD take the
-        //                     "anyone can spend" path immediately - WRONG!
+        // Since we provide wrong preimage, receiver path fails, so we try refund path.
+        // Refund path with refund keys present should require a signature.
         let refund_pubkey = PublicKey::from_hex(
             "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
         )
@@ -448,8 +477,8 @@ mod tests {
         let secret: SecretString = nut10_secret.try_into().unwrap();
 
         let htlc_witness = HTLCWitness {
-            preimage: hex::encode(&preimage_bytes),
-            signatures: None, // No signature provided
+            preimage: hex::encode(&wrong_preimage_bytes), // Wrong preimage!
+            signatures: None,                             // No signature provided
         };
 
         let proof = Proof {
@@ -464,13 +493,15 @@ mod tests {
             dleq: None,
         };
 
-        // Should FAIL because even though locktime passed, refund keys are present
-        // so the "anyone can spend" shortcut shouldn't apply. A signature is required.
-        // With && this correctly fails. With || it would incorrectly pass.
+        // Should FAIL because:
+        // 1. Wrong preimage means receiver path fails
+        // 2. Falls back to refund path (locktime passed)
+        // 3. Refund keys are present, so signature is required
+        // 4. No signature provided
         let result = proof.verify_htlc();
         assert!(
             result.is_err(),
-            "Should fail when locktime passed but refund keys present without signature"
+            "Should fail when using refund path with refund keys but no signature"
         );
     }
 }

+ 79 - 0
crates/cdk/src/mint/swap/tests/htlc_sigall_spending_conditions_tests.rs

@@ -387,3 +387,82 @@ async fn test_htlc_sig_all_multisig_2of3() {
     );
     println!("✓ HTLC SIG_ALL spent with preimage + 2-of-3 signatures");
 }
+
+/// Test: HTLC SIG_ALL receiver path still works after locktime (NUT-14 compliance)
+///
+/// Per NUT-14: "This pathway is ALWAYS available to the receivers, as possession
+/// of the preimage confirms performance of the Sender's wishes."
+///
+/// This test verifies that even after locktime has passed, the receiver can still
+/// spend using the preimage + pubkeys path with SIG_ALL (not just the refund path).
+#[tokio::test]
+async fn test_htlc_sig_all_receiver_path_after_locktime() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (_bob_secret, bob_pubkey) = create_test_keypair();
+    let (hash, preimage) = create_test_hash_and_preimage();
+
+    // Create HTLC with locktime in the PAST (already expired) and SIG_ALL flag
+    // Alice is the receiver (pubkeys), Bob is the refund key
+    let past_locktime = cdk_common::util::unix_time() - 1000;
+
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    let spending_conditions = SpendingConditions::new_htlc_hash(
+        &hash,
+        Some(Conditions {
+            locktime: Some(past_locktime),
+            pubkeys: Some(vec![alice_pubkey]),
+            refund_keys: Some(vec![bob_pubkey]),
+            num_sigs: None,
+            sig_flag: SigFlag::SigAll,
+            num_sigs_refund: None,
+        }),
+    )
+    .unwrap();
+
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (htlc_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    use cdk_common::dhke::construct_proofs;
+    let htlc_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Even though locktime has passed, Alice (receiver) should STILL be able to spend
+    // using the preimage + her SIG_ALL signature (receiver path is ALWAYS available per NUT-14)
+    use crate::test_helpers::mint::create_test_blinded_messages;
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    // Alice provides preimage and signs with SIG_ALL (receiver path)
+    swap_request.inputs_mut()[0].add_preimage(preimage.clone());
+    swap_request.sign_sig_all(alice_secret.clone()).unwrap();
+
+    let result = mint.process_swap_request(swap_request).await;
+    assert!(
+        result.is_ok(),
+        "Receiver should be able to spend with preimage + SIG_ALL even after locktime: {:?}",
+        result.err()
+    );
+    println!("✓ HTLC SIG_ALL receiver path works after locktime (NUT-14 compliant)");
+}

+ 81 - 0
crates/cdk/src/mint/swap/tests/htlc_spending_conditions_tests.rs

@@ -394,3 +394,84 @@ async fn test_htlc_multisig_2of3() {
     );
     println!("✓ HTLC spent with preimage + 2-of-3 signatures");
 }
+
+/// Test: HTLC receiver path still works after locktime (NUT-14 compliance)
+///
+/// Per NUT-14: "This pathway is ALWAYS available to the receivers, as possession
+/// of the preimage confirms performance of the Sender's wishes."
+///
+/// This test verifies that even after locktime has passed, the receiver can still
+/// spend using the preimage + pubkeys path (not just the refund path).
+#[tokio::test]
+async fn test_htlc_receiver_path_after_locktime() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (_bob_secret, bob_pubkey) = create_test_keypair();
+    let (hash, preimage) = create_test_hash_and_preimage();
+
+    // Create HTLC with locktime in the PAST (already expired)
+    // Alice is the receiver (pubkeys), Bob is the refund key
+    let past_locktime = cdk_common::util::unix_time() - 1000;
+
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    let spending_conditions = SpendingConditions::new_htlc_hash(
+        &hash,
+        Some(Conditions {
+            locktime: Some(past_locktime),
+            pubkeys: Some(vec![alice_pubkey]),
+            refund_keys: Some(vec![bob_pubkey]),
+            num_sigs: None,
+            sig_flag: SigFlag::default(),
+            num_sigs_refund: None,
+        }),
+    )
+    .unwrap();
+
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (htlc_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    use cdk_common::dhke::construct_proofs;
+    let htlc_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Even though locktime has passed, Alice (receiver) should STILL be able to spend
+    // using the preimage + her signature (receiver path is ALWAYS available per NUT-14)
+    use crate::test_helpers::mint::create_test_blinded_messages;
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    // Alice provides preimage and signs (receiver path)
+    for proof in swap_request.inputs_mut() {
+        proof.add_preimage(preimage.clone());
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request).await;
+    assert!(
+        result.is_ok(),
+        "Receiver should be able to spend with preimage even after locktime: {:?}",
+        result.err()
+    );
+    println!("✓ HTLC receiver path works after locktime (NUT-14 compliant)");
+}

+ 11 - 45
crates/cdk/src/mint/swap/tests/p2pk_sigall_spending_conditions_tests.rs

@@ -610,7 +610,8 @@ async fn test_p2pk_sig_all_locktime_after_expiry() {
     )
     .unwrap();
 
-    // Step 6: Try to spend with primary key (Alice) AFTER locktime expires (should fail)
+    // Step 6: Try to spend with primary key (Alice) AFTER locktime expires
+    // Per NUT-11: "Locktime Multisig conditions continue to apply" - primary keys STILL work
     let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
         .await
         .unwrap();
@@ -624,30 +625,11 @@ async fn test_p2pk_sig_all_locktime_after_expiry() {
 
     let result = mint.process_swap_request(swap_request_primary).await;
     assert!(
-        result.is_err(),
-        "Should fail - primary key cannot spend after locktime expires"
-    );
-    println!(
-        "✓ Spending with primary key (Alice) AFTER locktime failed as expected: {:?}",
-        result.err()
-    );
-
-    // Step 7: Spend with refund key (Bob) AFTER locktime (should succeed)
-    let mut swap_request_refund =
-        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
-
-    // Sign with Bob (refund key) using SIG_ALL
-    swap_request_refund
-        .sign_sig_all(bob_secret.clone())
-        .unwrap();
-
-    let result = mint.process_swap_request(swap_request_refund).await;
-    assert!(
         result.is_ok(),
-        "Should succeed - refund key can spend after locktime: {:?}",
+        "Should succeed - primary key can STILL spend after locktime (NUT-11 compliant): {:?}",
         result.err()
     );
-    println!("✓ Spending with refund key (Bob) AFTER locktime succeeded");
+    println!("✓ Spending with primary key (Alice) AFTER locktime succeeded (NUT-11 compliant)");
 }
 
 /// Test: P2PK with locktime after expiry, no refund keys (anyone can spend) - SIG_ALL
@@ -797,14 +779,15 @@ async fn test_p2pk_sig_all_multisig_locktime() {
     )
     .unwrap();
 
-    // Step 6: Try to spend with primary keys (Alice + Bob) AFTER locktime (should fail)
+    // Step 6: Try to spend with primary keys (Alice + Bob) AFTER locktime
+    // Per NUT-11: "Locktime Multisig conditions continue to apply" - primary keys STILL work
     let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
         .await
         .unwrap();
     let mut swap_request_primary =
         cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
 
-    // Sign with Alice + Bob (primary multisig) using SIG_ALL
+    // Sign with Alice + Bob (primary multisig - need 2-of-3) using SIG_ALL
     swap_request_primary
         .sign_sig_all(alice_secret.clone())
         .unwrap();
@@ -814,30 +797,13 @@ async fn test_p2pk_sig_all_multisig_locktime() {
 
     let result = mint.process_swap_request(swap_request_primary).await;
     assert!(
-        result.is_err(),
-        "Should fail - locktime expired, only refund keys valid"
-    );
-    println!(
-        "✓ Spending with primary keys (Alice + Bob) AFTER locktime failed as expected: {:?}",
-        result.err()
-    );
-
-    // Step 7: Spend with refund key (Dave) AFTER locktime (should succeed - only need 1-of-2)
-    let mut swap_request_refund =
-        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
-
-    // Sign with Dave only (refund key, need 1-of-2) using SIG_ALL
-    swap_request_refund
-        .sign_sig_all(dave_secret.clone())
-        .unwrap();
-
-    let result = mint.process_swap_request(swap_request_refund).await;
-    assert!(
         result.is_ok(),
-        "Should succeed - refund key can spend after locktime: {:?}",
+        "Should succeed - primary keys (2-of-3) can STILL spend after locktime (NUT-11): {:?}",
         result.err()
     );
-    println!("✓ Spending with refund key (Dave, 1-of-2) AFTER locktime succeeded");
+    println!(
+        "✓ Spending with primary keys (Alice + Bob, 2-of-3) AFTER locktime succeeded (NUT-11)"
+    );
 }
 
 /// Test: SIG_ALL with mixed proofs (different data) should fail

+ 11 - 45
crates/cdk/src/mint/swap/tests/p2pk_spending_conditions_tests.rs

@@ -428,7 +428,8 @@ async fn test_p2pk_locktime_after_expiry() {
     )
     .unwrap();
 
-    // Step 6: Try to spend with primary key (Alice) AFTER locktime expires (should fail)
+    // Step 6: Try to spend with primary key (Alice) AFTER locktime expires
+    // Per NUT-11: "Locktime Multisig conditions continue to apply" - primary keys STILL work
     let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
         .await
         .unwrap();
@@ -442,30 +443,11 @@ async fn test_p2pk_locktime_after_expiry() {
 
     let result = mint.process_swap_request(swap_request_primary).await;
     assert!(
-        result.is_err(),
-        "Should fail - primary key cannot spend after locktime expires"
-    );
-    println!(
-        "✓ Spending with primary key (Alice) AFTER locktime failed as expected: {:?}",
-        result.err()
-    );
-
-    // Step 7: Spend with refund key (Bob) AFTER locktime (should succeed)
-    let mut swap_request_refund =
-        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
-
-    // Sign with Bob (refund key)
-    for proof in swap_request_refund.inputs_mut() {
-        proof.sign_p2pk(bob_secret.clone()).unwrap();
-    }
-
-    let result = mint.process_swap_request(swap_request_refund).await;
-    assert!(
         result.is_ok(),
-        "Should succeed - refund key can spend after locktime: {:?}",
+        "Should succeed - primary key can STILL spend after locktime (NUT-11 compliant): {:?}",
         result.err()
     );
-    println!("✓ Spending with refund key (Bob) AFTER locktime succeeded");
+    println!("✓ Spending with primary key (Alice) AFTER locktime succeeded (NUT-11 compliant)");
 }
 
 /// Test: P2PK with locktime after expiry, no refund keys (anyone can spend)
@@ -615,14 +597,15 @@ async fn test_p2pk_multisig_locktime() {
     )
     .unwrap();
 
-    // Step 6: Try to spend with primary keys (Alice + Bob) AFTER locktime (should fail)
+    // Step 6: Try to spend with primary keys (Alice + Bob) AFTER locktime
+    // Per NUT-11: "Locktime Multisig conditions continue to apply" - primary keys STILL work
     let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
         .await
         .unwrap();
     let mut swap_request_primary =
         cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
 
-    // Sign with Alice + Bob (primary multisig)
+    // Sign with Alice + Bob (primary multisig - need 2-of-3)
     for proof in swap_request_primary.inputs_mut() {
         proof.sign_p2pk(alice_secret.clone()).unwrap();
         proof.sign_p2pk(bob_secret.clone()).unwrap();
@@ -630,30 +613,13 @@ async fn test_p2pk_multisig_locktime() {
 
     let result = mint.process_swap_request(swap_request_primary).await;
     assert!(
-        result.is_err(),
-        "Should fail - locktime expired, only refund keys valid"
-    );
-    println!(
-        "✓ Spending with primary keys (Alice + Bob) AFTER locktime failed as expected: {:?}",
-        result.err()
-    );
-
-    // Step 7: Spend with refund key (Dave) AFTER locktime (should succeed - only need 1-of-2)
-    let mut swap_request_refund =
-        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
-
-    // Sign with Dave only (refund key, need 1-of-2)
-    for proof in swap_request_refund.inputs_mut() {
-        proof.sign_p2pk(dave_secret.clone()).unwrap();
-    }
-
-    let result = mint.process_swap_request(swap_request_refund).await;
-    assert!(
         result.is_ok(),
-        "Should succeed - refund key can spend after locktime: {:?}",
+        "Should succeed - primary keys (2-of-3) can STILL spend after locktime (NUT-11): {:?}",
         result.err()
     );
-    println!("✓ Spending with refund key (Dave, 1-of-2) AFTER locktime succeeded");
+    println!(
+        "✓ Spending with primary keys (Alice + Bob, 2-of-3) AFTER locktime succeeded (NUT-11)"
+    );
 }
 
 /// Test: P2PK signed by wrong person is rejected