Skip to content

Commit 6455b58

Browse files
authored
fix: htlc and p2pk conditions with spec update (#1435)
1 parent 2592483 commit 6455b58

7 files changed

Lines changed: 581 additions & 312 deletions

File tree

crates/cashu/src/nuts/nut10.rs

Lines changed: 232 additions & 132 deletions
Large diffs are not rendered by default.

crates/cashu/src/nuts/nut11/mod.rs

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ impl Proof {
136136
}
137137

138138
/// Verify P2PK signature on [Proof]
139+
///
140+
/// Per NUT-11, there are two spending pathways after locktime:
141+
/// 1. Primary path (data + pubkeys): ALWAYS available
142+
/// 2. Refund path (refund keys): available AFTER locktime
143+
///
144+
/// The verification tries both paths - if either succeeds, the proof is valid.
139145
pub fn verify_p2pk(&self) -> Result<(), Error> {
140146
let secret: Nut10Secret = self.secret.clone().try_into()?;
141147
let spending_conditions: Conditions = secret
@@ -153,40 +159,70 @@ impl Proof {
153159
return Err(Error::IncorrectSecretKind);
154160
}
155161

156-
// Based on the current time, we must identify the relevant keys
162+
// Get spending requirements (includes both primary and refund paths)
157163
let now = unix_time();
158164
let requirements = super::nut10::get_pubkeys_and_required_sigs(&secret, now)?;
159165

160166
if requirements.preimage_needed {
161167
return Err(Error::PreimageNotSupportedInP2PK);
162168
}
163169

164-
// Handle "anyone can spend" case (locktime passed with no refund keys)
165-
if requirements.required_sigs == 0 {
166-
return Ok(());
167-
}
168-
169170
// Extract witness signatures
170171
let witness_signatures = match &self.witness {
171172
Some(witness) => witness.signatures(),
172173
None => None,
173174
};
174-
let witness_signatures = witness_signatures.ok_or(Error::SignaturesNotProvided)?;
175175

176-
// Count valid signatures using relevant_pubkeys
177176
let msg: &[u8] = self.secret.as_bytes();
178-
let valid_sig_count = valid_signatures(
179-
msg,
180-
&requirements.pubkeys,
181-
&witness_signatures
182-
.iter()
183-
.map(|s| Signature::from_str(s))
184-
.collect::<Result<Vec<_>, _>>()?,
185-
)?;
186177

187-
// Check if we have enough valid signatures
188-
if valid_sig_count >= requirements.required_sigs {
189-
Ok(())
178+
// Try primary path first (data + pubkeys)
179+
// Per NUT-11: "Locktime Multisig conditions continue to apply"
180+
{
181+
let primary_valid = witness_signatures
182+
.as_ref()
183+
.and_then(|sigs| {
184+
sigs.iter()
185+
.map(|s| Signature::from_str(s))
186+
.collect::<Result<Vec<_>, _>>()
187+
.ok()
188+
})
189+
.and_then(|sigs| valid_signatures(msg, &requirements.pubkeys, &sigs).ok())
190+
.is_some_and(|count| count >= requirements.required_sigs);
191+
192+
if primary_valid {
193+
return Ok(());
194+
}
195+
}
196+
197+
// Primary path failed or no signatures - try refund path if available
198+
{
199+
if let Some(refund_path) = &requirements.refund_path {
200+
// Anyone can spend (locktime passed, no refund keys)
201+
if refund_path.required_sigs == 0 {
202+
return Ok(());
203+
}
204+
205+
// Need signatures for refund path
206+
let refund_valid = witness_signatures
207+
.as_ref()
208+
.and_then(|sigs| {
209+
sigs.iter()
210+
.map(|s| Signature::from_str(s))
211+
.collect::<Result<Vec<_>, _>>()
212+
.ok()
213+
})
214+
.and_then(|sigs| valid_signatures(msg, &refund_path.pubkeys, &sigs).ok())
215+
.is_some_and(|count| count >= refund_path.required_sigs);
216+
217+
if refund_valid {
218+
return Ok(());
219+
}
220+
}
221+
}
222+
223+
// Neither path succeeded
224+
if witness_signatures.is_none() {
225+
Err(Error::SignaturesNotProvided)
190226
} else {
191227
Err(Error::SpendConditionsNotMet)
192228
}
@@ -1138,14 +1174,22 @@ mod tests {
11381174

11391175
proof.sign_p2pk(signing_key_three.clone()).unwrap();
11401176

1141-
assert!(proof.verify_p2pk().is_err());
1177+
// Per NUT-11: primary path (pubkeys) is ALWAYS available, even after locktime
1178+
// Signing with a key from pubkeys should succeed
1179+
assert!(proof.verify_p2pk().is_ok());
11421180

11431181
proof.witness = None;
11441182

1183+
// Sign with secret_key (pubkey = v_key, which is the data key and part of primary path)
1184+
// Per NUT-11: primary path is always available, and data key is part of primary path
11451185
proof.sign_p2pk(secret_key).unwrap();
1146-
assert!(proof.verify_p2pk().is_err());
1147-
proof.sign_p2pk(signing_key_two).unwrap();
1186+
assert!(
1187+
proof.verify_p2pk().is_ok(),
1188+
"Data key signature should satisfy primary path"
1189+
);
11481190

1191+
// Adding more signatures still works (but wasn't needed for primary path)
1192+
proof.sign_p2pk(signing_key_two).unwrap();
11491193
assert!(proof.verify_p2pk().is_ok());
11501194
}
11511195

@@ -1725,15 +1769,17 @@ mod tests {
17251769

17261770
#[test]
17271771
fn test_sig_all_htlc_post_locktime() {
1728-
// The following is a valid SwapRequest with a multisig HTLC also locked to locktime and refund keys.
1772+
// The following is a valid SwapRequest with a multisig HTLC using the refund path.
1773+
// Per NUT-14: After locktime, the refund path is available when preimage is invalid/missing.
1774+
// The preimage is intentionally invalid (all zeros) to test the refund path.
17291775
let valid_swap = r#"{
17301776
"inputs": [
17311777
{
17321778
"amount": 2,
17331779
"id": "00bfa73302d12ffd",
17341780
"secret": "[\"HTLC\",{\"nonce\":\"c9b0fabb8007c0db4bef64d5d128cdcf3c79e8bb780c3294adf4c88e96c32647\",\"data\":\"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5\",\"tags\":[[\"pubkeys\",\"039e6ec7e922abb4162235b3a42965eb11510b07b7461f6b1a17478b1c9c64d100\"],[\"locktime\",\"1\"],[\"refund\",\"02ce1bbd2c9a4be8029c9a6435ad601c45677f5cde81f8a7f0ed535e0039d0eb6c\",\"03c43c00ff57f63cfa9e732f0520c342123e21331d0121139f1b636921eeec095f\"],[\"n_sigs_refund\",\"2\"],[\"sigflag\",\"SIG_ALL\"]]}]",
17351781
"C": "0344b6f1471cf18a8cbae0e624018c816be5e3a9b04dcb7689f64173c1ae90a3a5",
1736-
"witness": "{\"preimage\":\"0000000000000000000000000000000000000000000000000000000000000001\",\"signatures\":[\"98e21672d409cc782c720f203d8284f0af0c8713f18167499f9f101b7050c3e657fb0e57478ebd8bd561c31aa6c30f4cd20ec38c73f5755b7b4ddee693bca5a5\",\"693f40129dbf905ed9c8008081c694f72a36de354f9f4fa7a61b389cf781f62a0ae0586612fb2eb504faaf897fefb6742309186117f4743bcebcb8e350e975e2\"]}"
1782+
"witness": "{\"preimage\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"signatures\":[\"98e21672d409cc782c720f203d8284f0af0c8713f18167499f9f101b7050c3e657fb0e57478ebd8bd561c31aa6c30f4cd20ec38c73f5755b7b4ddee693bca5a5\",\"693f40129dbf905ed9c8008081c694f72a36de354f9f4fa7a61b389cf781f62a0ae0586612fb2eb504faaf897fefb6742309186117f4743bcebcb8e350e975e2\"]}"
17371783
}
17381784
],
17391785
"outputs": [

crates/cashu/src/nuts/nut14/mod.rs

Lines changed: 97 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ impl HTLCWitness {
9494

9595
impl Proof {
9696
/// Verify HTLC
97+
///
98+
/// Per NUT-14, there are two spending pathways:
99+
/// 1. Receiver path (preimage + pubkeys): ALWAYS available
100+
/// 2. Sender/Refund path (refund keys, no preimage): available AFTER locktime
101+
///
102+
/// The verification tries to determine which path is being used based on
103+
/// the witness provided, then validates accordingly.
97104
pub fn verify_htlc(&self) -> Result<(), Error> {
98105
let secret: Secret = self.secret.clone().try_into()?;
99106
let spending_conditions: Conditions = secret
@@ -111,65 +118,86 @@ impl Proof {
111118
return Err(Error::IncorrectSecretKind);
112119
}
113120

114-
// Get the appropriate spending conditions based on locktime
121+
// Get the spending requirements (includes both receiver and refund paths)
115122
let now = unix_time();
116123
let requirements =
117124
super::nut10::get_pubkeys_and_required_sigs(&secret, now).map_err(Error::NUT11)?;
118125

119-
// While a Witness is usually needed in a P2PK or HTLC proof, it's not
120-
// always needed. If we are past the locktime, and there are no refund
121-
// keys, then the proofs are anyone-can-spend:
122-
// NUT-11: "If the tag locktime is the unix time and the mint's local
123-
// clock is greater than locktime, the Proof becomes spendable
124-
// by anyone, except if [there are no refund keys]"
125-
// Therefore, this function should not extract any Witness unless it
126-
// is needed to get a preimage or signatures.
127-
128-
// If preimage is needed (before locktime), verify it
129-
if requirements.preimage_needed {
130-
// Extract HTLC witness
131-
let htlc_witness = match &self.witness {
132-
Some(Witness::HTLCWitness(witness)) => witness,
133-
_ => return Err(Error::IncorrectSecretKind),
134-
};
135-
136-
// Verify preimage using shared function
137-
super::nut10::verify_htlc_preimage(htlc_witness, &secret)?;
138-
}
139-
140-
if requirements.required_sigs == 0 {
141-
return Ok(());
142-
}
143-
144-
// if we get here, the preimage check (if it was needed) has been done
145-
// and we know that at least one signature is required. So, we extract
146-
// the witness.signatures and count them:
147-
148-
// Extract witness signatures
126+
// Try to extract HTLC witness - must be correct type
149127
let htlc_witness = match &self.witness {
150128
Some(Witness::HTLCWitness(witness)) => witness,
151-
_ => return Err(Error::IncorrectSecretKind),
129+
_ => {
130+
// Wrong witness type or no witness
131+
// If refund path is available with 0 required sigs, anyone can spend
132+
if let Some(refund_path) = &requirements.refund_path {
133+
if refund_path.required_sigs == 0 {
134+
return Ok(());
135+
}
136+
}
137+
return Err(Error::IncorrectSecretKind);
138+
}
152139
};
153-
let witness_signatures = htlc_witness
154-
.signatures
155-
.as_ref()
156-
.ok_or(Error::SignaturesNotProvided)?;
157140

158-
// Convert signatures from strings
159-
let signatures: Vec<Signature> = witness_signatures
160-
.iter()
161-
.map(|s| Signature::from_str(s))
162-
.collect::<Result<Vec<_>, _>>()?;
163-
164-
// Count valid signatures using relevant_pubkeys
165-
let msg: &[u8] = self.secret.as_bytes();
166-
let valid_sig_count = valid_signatures(msg, &requirements.pubkeys, &signatures)?;
167-
168-
// Check if we have enough valid signatures
169-
if valid_sig_count >= requirements.required_sigs {
170-
Ok(())
141+
// Try to verify the preimage and capture the specific error if it fails
142+
let preimage_result = super::nut10::verify_htlc_preimage(htlc_witness, &secret);
143+
144+
// Determine which path to use:
145+
// - If preimage is valid → use receiver path (always available)
146+
// - If preimage is invalid/missing → try refund path (if available)
147+
if preimage_result.is_ok() {
148+
// Receiver path: preimage valid, now check signatures against pubkeys
149+
if requirements.required_sigs == 0 {
150+
return Ok(());
151+
}
152+
153+
let witness_signatures = htlc_witness
154+
.signatures
155+
.as_ref()
156+
.ok_or(Error::SignaturesNotProvided)?;
157+
158+
let signatures: Vec<Signature> = witness_signatures
159+
.iter()
160+
.map(|s| Signature::from_str(s))
161+
.collect::<Result<Vec<_>, _>>()?;
162+
163+
let msg: &[u8] = self.secret.as_bytes();
164+
let valid_sig_count = valid_signatures(msg, &requirements.pubkeys, &signatures)?;
165+
166+
if valid_sig_count >= requirements.required_sigs {
167+
Ok(())
168+
} else {
169+
Err(Error::NUT11(super::nut11::Error::SpendConditionsNotMet))
170+
}
171+
} else if let Some(refund_path) = &requirements.refund_path {
172+
// Refund path: preimage not valid/provided, but locktime has passed
173+
// Check signatures against refund keys
174+
if refund_path.required_sigs == 0 {
175+
// Anyone can spend (locktime passed, no refund keys)
176+
return Ok(());
177+
}
178+
179+
let witness_signatures = htlc_witness
180+
.signatures
181+
.as_ref()
182+
.ok_or(Error::SignaturesNotProvided)?;
183+
184+
let signatures: Vec<Signature> = witness_signatures
185+
.iter()
186+
.map(|s| Signature::from_str(s))
187+
.collect::<Result<Vec<_>, _>>()?;
188+
189+
let msg: &[u8] = self.secret.as_bytes();
190+
let valid_sig_count = valid_signatures(msg, &refund_path.pubkeys, &signatures)?;
191+
192+
if valid_sig_count >= refund_path.required_sigs {
193+
Ok(())
194+
} else {
195+
Err(Error::NUT11(super::nut11::Error::SpendConditionsNotMet))
196+
}
171197
} else {
172-
Err(Error::NUT11(super::nut11::Error::SpendConditionsNotMet))
198+
// No valid preimage and refund path not available (locktime not passed)
199+
// Return the specific error from preimage verification
200+
preimage_result
173201
}
174202
}
175203

@@ -411,25 +439,26 @@ mod tests {
411439

412440
/// Tests that verify_htlc requires BOTH locktime expired AND no refund keys for "anyone can spend".
413441
///
414-
/// This test catches the mutation that replaces `&&` with `||` at line 83.
415-
/// The logic should be: (locktime expired AND no refund keys) → anyone can spend.
416-
/// If mutated to OR, it would allow spending when locktime passed even if refund keys exist.
442+
/// This test verifies that when locktime has passed and refund keys are present,
443+
/// a signature from the refund keys is required (not anyone-can-spend).
417444
///
418-
/// Mutant testing: Catches mutations that replace `&&` with `||` in the locktime check.
445+
/// Per NUT-14: After locktime, the refund path requires signatures from refund keys.
446+
/// The "anyone can spend" case only applies when locktime passed AND no refund keys.
419447
#[test]
420448
fn test_htlc_locktime_and_refund_keys_logic() {
421449
use crate::nuts::nut01::PublicKey;
422450
use crate::nuts::nut11::Conditions;
423451

424-
let preimage_bytes = [42u8; 32]; // 32-byte preimage
425-
let hash = Sha256Hash::hash(&preimage_bytes);
452+
let correct_preimage_bytes = [42u8; 32]; // 32-byte preimage
453+
let hash = Sha256Hash::hash(&correct_preimage_bytes);
426454
let hash_str = hash.to_string();
427455

456+
// Use WRONG preimage to force using refund path (not receiver path)
457+
let wrong_preimage_bytes = [99u8; 32];
458+
428459
// Test: Locktime has passed (locktime=1) but refund keys ARE present
429-
// With correct logic (&&): Since refund_keys.is_none() is false, the "anyone can spend"
430-
// path is NOT taken, so signature is required
431-
// With mutation (||): Since locktime.lt(&unix_time()) is true, it WOULD take the
432-
// "anyone can spend" path immediately - WRONG!
460+
// Since we provide wrong preimage, receiver path fails, so we try refund path.
461+
// Refund path with refund keys present should require a signature.
433462
let refund_pubkey = PublicKey::from_hex(
434463
"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
435464
)
@@ -448,8 +477,8 @@ mod tests {
448477
let secret: SecretString = nut10_secret.try_into().unwrap();
449478

450479
let htlc_witness = HTLCWitness {
451-
preimage: hex::encode(&preimage_bytes),
452-
signatures: None, // No signature provided
480+
preimage: hex::encode(&wrong_preimage_bytes), // Wrong preimage!
481+
signatures: None, // No signature provided
453482
};
454483

455484
let proof = Proof {
@@ -464,13 +493,15 @@ mod tests {
464493
dleq: None,
465494
};
466495

467-
// Should FAIL because even though locktime passed, refund keys are present
468-
// so the "anyone can spend" shortcut shouldn't apply. A signature is required.
469-
// With && this correctly fails. With || it would incorrectly pass.
496+
// Should FAIL because:
497+
// 1. Wrong preimage means receiver path fails
498+
// 2. Falls back to refund path (locktime passed)
499+
// 3. Refund keys are present, so signature is required
500+
// 4. No signature provided
470501
let result = proof.verify_htlc();
471502
assert!(
472503
result.is_err(),
473-
"Should fail when locktime passed but refund keys present without signature"
504+
"Should fail when using refund path with refund keys but no signature"
474505
);
475506
}
476507
}

0 commit comments

Comments
 (0)