* fix: refund keys must sign after locktime

* fix: custom tags

* refactor: locktime verification and refund
This commit is contained in:
thesimplekid
2025-08-26 06:29:20 +01:00
committed by GitHub
parent 9ab86fabfe
commit 809d9c8012

View File

@@ -146,6 +146,45 @@ impl Proof {
let witness_signatures = witness_signatures.ok_or(Error::SignaturesNotProvided)?;
let mut pubkeys = spending_conditions.pubkeys.clone().unwrap_or_default();
// NUT-11 enforcement per spec:
// - If locktime has passed and refund keys are present, spend must be authorized by
// refund pubkeys (n_sigs_refund-of-refund). This supersedes normal pubkey enforcement
// after expiry.
// - If locktime has passed and no refund keys are present, proof becomes spendable
// without further key checks (anyone-can-spend behavior).
// - Otherwise (before locktime), enforce normal multisig on the set of authorized
// pubkeys: Secret.data plus optional `pubkeys` tag, requiring n_sigs unique signers.
let now = unix_time();
if let Some(locktime) = spending_conditions.locktime {
if now >= locktime {
if let Some(refund_keys) = spending_conditions.refund_keys.clone() {
let needed_refund_sigs =
spending_conditions.num_sigs_refund.unwrap_or(1) as usize;
let mut valid_pubkeys = HashSet::new();
// After locktime, require signatures from refund keys
for s in witness_signatures.iter() {
let sig = Signature::from_str(s).map_err(|_| Error::InvalidSignature)?;
for v in &refund_keys {
if v.verify(msg, &sig).is_ok() {
valid_pubkeys.insert(v);
if valid_pubkeys.len() >= needed_refund_sigs {
return Ok(());
}
}
}
}
// If locktime and refund keys were specified they must sign after locktime
return Err(Error::SpendConditionsNotMet);
} else {
// If only locktime is specified, consider it spendable after locktime
return Ok(());
}
}
}
if secret.kind().eq(&Kind::P2PK) {
pubkeys.push(PublicKey::from_str(secret.secret_data().data())?);
@@ -175,34 +214,6 @@ impl Proof {
return Ok(());
}
if let (Some(locktime), Some(refund_keys)) = (
spending_conditions.locktime,
spending_conditions.refund_keys,
) {
let needed_refund_sigs = spending_conditions.num_sigs_refund.unwrap_or(1) as usize;
let mut valid_pubkeys = HashSet::new();
// If lock time has passed check if refund witness signature is valid
if locktime.lt(&unix_time()) {
for s in witness_signatures.iter() {
for v in &refund_keys {
let sig = Signature::from_str(s).map_err(|_| Error::InvalidSignature)?;
if v.verify(msg, &sig).is_ok() {
if !valid_pubkeys.insert(v) {
return Err(Error::DuplicateSignature);
}
if valid_pubkeys.len() >= needed_refund_sigs {
return Ok(());
}
}
}
}
}
}
Err(Error::SpendConditionsNotMet)
}
}
@@ -502,7 +513,7 @@ impl From<Conditions> for Vec<Vec<String>> {
refund_keys,
num_sigs,
sig_flag,
num_sigs_refund: _,
num_sigs_refund,
} = conditions;
let mut tags = Vec::new();
@@ -522,6 +533,11 @@ impl From<Conditions> for Vec<Vec<String>> {
if let Some(refund_keys) = refund_keys {
tags.push(Tag::Refund(refund_keys).as_vec())
}
if let Some(num_sigs_refund) = num_sigs_refund {
tags.push(Tag::NSigsRefund(num_sigs_refund).as_vec())
}
tags.push(Tag::SigFlag(sig_flag).as_vec());
tags
}
@@ -577,13 +593,22 @@ impl TryFrom<Vec<Vec<String>>> for Conditions {
None
};
let num_sigs_refund = if let Some(tag) = tags.get(&TagKind::NSigsRefund) {
match tag {
Tag::NSigsRefund(num_sigs) => Some(*num_sigs),
_ => None,
}
} else {
None
};
Ok(Conditions {
locktime,
pubkeys,
refund_keys,
num_sigs,
sig_flag,
num_sigs_refund: None,
num_sigs_refund,
})
}
}
@@ -619,7 +644,7 @@ impl fmt::Display for TagKind {
Self::Refund => write!(f, "refund"),
Self::Pubkeys => write!(f, "pubkeys"),
Self::NSigsRefund => write!(f, "n_sigs_refund"),
Self::Custom(kind) => write!(f, "{kind}"),
Self::Custom(c) => write!(f, "{c}"),
}
}
}
@@ -635,6 +660,7 @@ where
"locktime" => Self::Locktime,
"refund" => Self::Refund,
"pubkeys" => Self::Pubkeys,
"n_sigs_refund" => Self::NSigsRefund,
t => Self::Custom(t.to_owned()),
}
}
@@ -741,6 +767,10 @@ pub enum Tag {
Refund(Vec<PublicKey>),
/// Pubkeys [`Tag`]
PubKeys(Vec<PublicKey>),
/// Number of Sigs refund [`Tag`]
NSigsRefund(u64),
/// Custom tag
Custom(String, Vec<String>),
}
impl Tag {
@@ -752,6 +782,8 @@ impl Tag {
Self::LockTime(_) => TagKind::Locktime,
Self::Refund(_) => TagKind::Refund,
Self::PubKeys(_) => TagKind::Pubkeys,
Self::NSigsRefund(_) => TagKind::NSigsRefund,
Self::Custom(tag, _) => TagKind::Custom(tag.to_string()),
}
}
@@ -792,7 +824,16 @@ where
Ok(Self::PubKeys(pubkeys))
}
_ => Err(Error::UnknownTag),
TagKind::NSigsRefund => Ok(Tag::NSigsRefund(tag[1].as_ref().parse()?)),
TagKind::Custom(name) => {
let tags = tag
.iter()
.skip(1)
.map(|p| p.as_ref().to_string())
.collect::<Vec<String>>();
Ok(Self::Custom(name, tags))
}
}
}
}
@@ -818,6 +859,18 @@ impl From<Tag> for Vec<String> {
}
tag
}
Tag::NSigsRefund(num_sigs) => {
vec![TagKind::NSigsRefund.to_string(), num_sigs.to_string()]
}
Tag::Custom(name, c) => {
let mut tag = vec![name];
for t in c {
tag.push(t);
}
tag
}
}
}
}
@@ -1360,6 +1413,61 @@ mod tests {
assert!(invalid_proof.verify_p2pk().is_err());
}
#[test]
fn sig_with_non_refund_keys_after_locktime() {
let secret_key =
SecretKey::from_str("99590802251e78ee1051648439eedb003dc539093a48a44e7b8f2642c909ea37")
.unwrap();
let signing_key_two =
SecretKey::from_str("0000000000000000000000000000000000000000000000000000000000000001")
.unwrap();
let signing_key_three =
SecretKey::from_str("7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f")
.unwrap();
let v_key: PublicKey = secret_key.public_key();
let v_key_two: PublicKey = signing_key_two.public_key();
let v_key_three: PublicKey = signing_key_three.public_key();
let conditions = Conditions {
locktime: Some(21),
pubkeys: Some(vec![v_key_three]),
refund_keys: Some(vec![v_key, v_key_two]),
num_sigs: None,
sig_flag: SigFlag::SigInputs,
num_sigs_refund: Some(2),
};
let secret: Secret = Nut10Secret::new(Kind::P2PK, v_key.to_string(), Some(conditions))
.try_into()
.unwrap();
let mut proof = Proof {
keyset_id: Id::from_str("009a1f293253e41e").unwrap(),
amount: Amount::ZERO,
secret,
c: PublicKey::from_str(
"02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904",
)
.unwrap(),
witness: Some(Witness::P2PKWitness(P2PKWitness { signatures: vec![] })),
dleq: None,
};
proof.sign_p2pk(signing_key_three.clone()).unwrap();
assert!(proof.verify_p2pk().is_err());
proof.witness = None;
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());
}
// Helper functions for melt request tests
fn create_test_proof(secret: Secret, pubkey: PublicKey, id: &str) -> Proof {
Proof {