Skip to content

Commit a2b88b2

Browse files
committed
Mark timelock kind conflicts as Witness::Impossible in concatenate_rev.
Mixed height/time merges now invalidate the whole satisfaction path instead of leaving a valid stack with None timelock metadata. Add max helpers on locktime types and update satisfier docs.
1 parent 40476dd commit a2b88b2

4 files changed

Lines changed: 77 additions & 39 deletions

File tree

src/miniscript/satisfy/mod.rs

Lines changed: 57 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -93,18 +93,20 @@ pub trait Satisfier<Pk: MiniscriptKey + ToPublicKey> {
9393
/// Given a HASH160 hash, look up its preimage
9494
fn lookup_hash160(&self, _: &Pk::Hash160) -> Option<Preimage32> { None }
9595

96-
/// Assert whether an relative locktime is satisfied
96+
/// Returns whether the given relative locktime is satisfied.
9797
///
98-
/// NOTE: If a descriptor mixes time-based and height-based timelocks, the implementation of
99-
/// this method MUST only allow timelocks of either unit, but not both. Allowing both could cause
100-
/// miniscript to construct an invalid witness.
98+
/// Composite satisfiers may expose both height- and time-based locktimes.
99+
/// If a single witness path would require incompatible timelock kinds
100+
/// simultaneously, satisfaction for that path becomes [`Witness::Impossible`]
101+
/// and plan building fails or selects another path.
101102
fn check_older(&self, _: relative::LockTime) -> bool { false }
102103

103-
/// Assert whether a absolute locktime is satisfied
104+
/// Returns whether the given absolute locktime is satisfied.
104105
///
105-
/// NOTE: If a descriptor mixes time-based and height-based timelocks, the implementation of
106-
/// this method MUST only allow timelocks of either unit, but not both. Allowing both could cause
107-
/// miniscript to construct an invalid witness.
106+
/// Composite satisfiers may expose both height- and time-based locktimes.
107+
/// If a single witness path would require incompatible timelock kinds
108+
/// simultaneously, satisfaction for that path becomes [`Witness::Impossible`]
109+
/// and plan building fails or selects another path.
108110
fn check_after(&self, _: absolute::LockTime) -> bool { false }
109111
}
110112

@@ -674,29 +676,27 @@ mod tests {
674676
// Same descriptor as above, except that now we use a time-based timelock rather than a
675677
// lower height-based one.
676678
//
677-
// Again, we wind up taking both timelock branches. Prior to #895, we would do this because
678-
// we did not track the after(144) at all. After #895, we do it because our timelock logic
679-
// handles mixed timelocks by silently clobbering one of them. (See the implementation of
680-
// `concatenate_rev`, which calls `cmp::max` on the timelocks. This clobbers a locktime.
681-
// See #979 for an explanation of this.)
682-
//
683-
// This time the "ideal" behavior would be that we track best satisfactions for both time-
684-
// and height-based locktimes, and whenever we are forced into a conflict we throw one away.
685-
// (The type system guarantees that we will always have at least one satisfaction left;
686-
// otherwise the whole script would be flagged as mixing timelocks.)
687-
//
688-
// For now we just use this unit test to document the behavior.
679+
// Again, both timelock branches are considered. When `concatenate_rev` must merge a
680+
// height-based and a time-based absolute locktime on the same path, that satisfaction
681+
// becomes `Witness::Impossible`. Plan building then fails or selects an alternate path.
682+
// Parallel per-kind tracking remains future work (see #979 discussion).
689683
let descriptor_str = format!(
690684
"wsh(or_b(n:or_i(and_v(v:after(144),and_v(v:pk({}),pk({}))),{expensive_threshold}),ajt:and_v(v:after(1000000000),v:pk({}))))",
691685
available_keys[0], unavailable_keys[0], available_keys[1],
692686
);
693687
let descriptor =
694688
Descriptor::<crate::DefiniteDescriptorKey>::from_str(&descriptor_str).unwrap();
695689

696-
let plan = descriptor.into_plan_mall(&satisfier).unwrap();
690+
// Both `or_b` arms hit a height/time conflict in `concatenate_rev`, so no malleable
691+
// plan exists.
692+
assert!(descriptor.clone().into_plan_mall(&satisfier).is_err());
693+
// Non-malleable plan still succeeds: `into_plan` dissatisfies the expensive threshold
694+
// branch and satisfies the time-based `after(1000000000)` branch without merging
695+
// incompatible locktimes on one path.
696+
let plan = descriptor.into_plan(&satisfier).unwrap();
697697
assert_eq!(
698698
plan.absolute_timelock,
699-
Some(absolute::LockTime::from_time(1000000000).unwrap()),
699+
Some(absolute::LockTime::from_time(1_000_000_000).unwrap()),
700700
);
701701
}
702702
}
@@ -1029,6 +1029,13 @@ pub struct Satisfaction<T> {
10291029
}
10301030

10311031
impl<Pk: MiniscriptKey + ToPublicKey> Satisfaction<Placeholder<Pk>> {
1032+
const IMPOSSIBLE: Self = Self {
1033+
stack: Witness::Impossible,
1034+
has_sig: false,
1035+
relative_timelock: None,
1036+
absolute_timelock: None,
1037+
};
1038+
10321039
/// The empty satisfaction.
10331040
///
10341041
/// This has the property that, when concatenated on either side with another satisfaction
@@ -1048,11 +1055,36 @@ impl<Pk: MiniscriptKey + ToPublicKey> Satisfaction<Placeholder<Pk>> {
10481055
/// This order allows callers to write `left.concatenate_rev(right)` which feels more
10491056
/// natural than the opposite order, and more importantly, allows this method to be
10501057
/// used when folding over an iterator of multiple satisfactions.
1058+
///
1059+
/// Same-unit locktimes merge to the later value via [`AbsLockTime::max`] and
1060+
/// [`RelLockTime::max`]. Mixed height/time on the same path yields
1061+
/// [`Witness::Impossible`]. Downstream [`Self::minimum`], [`Self::minimum_mall`], and
1062+
/// [`Self::thresh`] treat Impossible like other dead branches; [`Descriptor::into_plan`] and
1063+
/// [`Descriptor::into_plan_mall`] fail if the winning path is Impossible.
10511064
fn concatenate_rev(self, other: Self) -> Self {
1065+
if self.stack == Witness::Impossible || other.stack == Witness::Impossible {
1066+
return Self::IMPOSSIBLE;
1067+
}
1068+
1069+
let relative_timelock = match (self.relative_timelock, other.relative_timelock) {
1070+
(None, x) | (x, None) => x,
1071+
(Some(a), Some(b)) => match RelLockTime::max(a, b) {
1072+
Some(t) => Some(t),
1073+
None => return Self::IMPOSSIBLE,
1074+
},
1075+
};
1076+
let absolute_timelock = match (self.absolute_timelock, other.absolute_timelock) {
1077+
(None, x) | (x, None) => x,
1078+
(Some(a), Some(b)) => match AbsLockTime::max(a, b) {
1079+
Some(t) => Some(t),
1080+
None => return Self::IMPOSSIBLE,
1081+
},
1082+
};
1083+
10521084
Self {
10531085
has_sig: self.has_sig || other.has_sig,
1054-
relative_timelock: cmp::max(self.relative_timelock, other.relative_timelock),
1055-
absolute_timelock: cmp::max(self.absolute_timelock, other.absolute_timelock),
1086+
relative_timelock,
1087+
absolute_timelock,
10561088
stack: Witness::combine(other.stack, self.stack),
10571089
}
10581090
}
@@ -1120,14 +1152,7 @@ impl<Pk: MiniscriptKey + ToPublicKey> Satisfaction<Placeholder<Pk>> {
11201152
// For example, the fragment thresh(2, hash, 0, 0, 0)
11211153
// is has an impossible witness
11221154
if sats[sat_indices[k - 1]].stack == Witness::Impossible {
1123-
Self {
1124-
stack: Witness::Impossible,
1125-
// If the witness is impossible, we don't care about the
1126-
// has_sig flag, nor about the timelocks
1127-
has_sig: false,
1128-
relative_timelock: None,
1129-
absolute_timelock: None,
1130-
}
1155+
Self::IMPOSSIBLE
11311156
}
11321157
// We are now guaranteed that all elements in `k` satisfactions
11331158
// are not impossible(we sort by is_impossible bool).

src/miniscript/satisfy/sat_dissat.rs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,6 @@ pub(super) struct SatDissat<Pk: MiniscriptKey> {
2121
}
2222

2323
impl<Pk: MiniscriptKey + ToPublicKey> Satisfaction<Placeholder<Pk>> {
24-
const IMPOSSIBLE: Self = Self {
25-
stack: Witness::Impossible,
26-
has_sig: false,
27-
relative_timelock: None,
28-
absolute_timelock: None,
29-
};
30-
3124
const TRIVIAL: Self = Self {
3225
stack: Witness::empty(),
3326
has_sig: false,

src/primitives/absolute_locktime.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ impl AbsLockTime {
7373
pub(crate) fn cmp_by_consensus(self, other: Self) -> cmp::Ordering {
7474
self.to_consensus_u32().cmp(&other.to_consensus_u32())
7575
}
76+
77+
/// Returns the later of two locktimes of the same unit, or `None` if units differ.
78+
pub(crate) fn max(a: Self, b: Self) -> Option<Self> {
79+
use core::cmp::Ordering::*;
80+
match absolute::LockTime::from(a).partial_cmp(&absolute::LockTime::from(b)) {
81+
Some(Greater) | Some(Equal) => Some(a),
82+
Some(Less) => Some(b),
83+
None => None,
84+
}
85+
}
7686
}
7787

7888
impl From<AbsLockTime> for absolute::LockTime {

src/primitives/relative_locktime.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ impl RelLockTime {
7070
pub(crate) fn cmp_by_consensus(self, other: Self) -> cmp::Ordering {
7171
self.to_consensus_u32().cmp(&other.to_consensus_u32())
7272
}
73+
74+
/// Returns the later of two locktimes of the same unit, or `None` if units differ.
75+
pub(crate) fn max(a: Self, b: Self) -> Option<Self> {
76+
use core::cmp::Ordering::*;
77+
match relative::LockTime::from(a).partial_cmp(&relative::LockTime::from(b)) {
78+
Some(Greater) | Some(Equal) => Some(a),
79+
Some(Less) => Some(b),
80+
None => None,
81+
}
82+
}
7383
}
7484

7585
impl convert::TryFrom<Sequence> for RelLockTime {

0 commit comments

Comments
 (0)