Skip to content

Commit 460d5f1

Browse files
committed
Merge #982: primitives: remove Ord from AbsLockTime and RelLockTime
476dac5 Remove Ord and PartialOrd from AbsLockTime and RelLockTime. (Henry Romp) 013ad7d Mark timelock kind conflicts as Witness::Impossible in concatenate_rev. (Henry Romp) 46b2812 Sort same-kind locktimes by consensus u32 in Terminal::cmp. (Henry Romp) f168ca0 Add manual Ord impls for concrete and semantic Policy. (Henry Romp) 99501a5 miniscript: don't use malleable satisfier, even for or_i dissatisfactions (Andrew Poelstra) cfb93eb descriptor: fix up regression_895 test (Andrew Poelstra) Pull request description: This PR implements #979 by removing `Ord` and `PartialOrd` from `AbsLockTime` and `RelLockTime`. Those impls compared height- and time-based locktimes as raw consensus `u32` values, which is not a meaningful total order. The wrapper types exist because Miniscript locktimes cannot be zero; ordering was incidental. Policy types still need a total order, so we replace `derive(Ord)` on concrete and semantic `Policy` with explicit variant-name ordering and consensus-u32 comparison of matching `After`/`Older` nodes via `cmp_by_consensus` on the locktime types. `Terminal::cmp` uses the same method, and `Miniscript::Ord` picks that up through `node.cmp`. Policy and Miniscript ordering may change; that ordering was never a stable public contract. In `concatenate_rev`, same-unit locktimes merge via `max` (bitcoin `partial_cmp`); mixed height/time on one path yields `Witness::Impossible`, so plan building fails or picks another branch. Composite satisfiers may still expose both locktime kinds. `regression_895` expects `into_plan_mall` to fail on the height-vs-time case while `into_plan` still succeeds with the time-based locktime. Stacked on PR #980 (`2026-06/mallsat`) — merge #980 first. Breaking changes; changelog and version bump left to maintainers. Fixes #979 **Tested with:** `cargo test --all-features` — passed `just fmt-check` — passed `just sane` — passed ACKs for top commit: apoelstra: ACK 476dac5; successfully ran local tests Tree-SHA512: b0aec263d46a1219c7089a3bd89026da661deacf2f9fafe66238716a4eeb5bdddec2a75b7fc8f21f59900c299c1feee765ed188eb57cab384936e3f613ce841d
2 parents 9a58a4e + 476dac5 commit 460d5f1

8 files changed

Lines changed: 325 additions & 113 deletions

File tree

src/miniscript/display.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,8 +343,12 @@ impl<Pk: MiniscriptKey, Ctx: ScriptContext> Ord for Terminal<Pk, Ctx> {
343343
(DisplayNode::ThresholdK(me), DisplayNode::ThresholdK(you)) => me.cmp(&you),
344344
(DisplayNode::Key(me), DisplayNode::Key(you)) => me.cmp(you),
345345
(DisplayNode::RawKeyHash(me), DisplayNode::RawKeyHash(you)) => me.cmp(you),
346-
(DisplayNode::After(me), DisplayNode::After(you)) => me.cmp(you),
347-
(DisplayNode::Older(me), DisplayNode::Older(you)) => me.cmp(you),
346+
(DisplayNode::After(me), DisplayNode::After(you)) => {
347+
me.cmp_by_consensus(*you)
348+
}
349+
(DisplayNode::Older(me), DisplayNode::Older(you)) => {
350+
me.cmp_by_consensus(*you)
351+
}
348352
(DisplayNode::Sha256(me), DisplayNode::Sha256(you)) => me.cmp(you),
349353
(DisplayNode::Hash256(me), DisplayNode::Hash256(you)) => me.cmp(you),
350354
(DisplayNode::Ripemd160(me), DisplayNode::Ripemd160(you)) => me.cmp(you),

src/miniscript/mod.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1262,7 +1262,8 @@ mod tests {
12621262
use crate::policy::Liftable;
12631263
use crate::test_utils::{StrKeyTranslator, StrXOnlyKeyTranslator};
12641264
use crate::{
1265-
hex_script, BareCtx, Error, Legacy, RelLockTime, Satisfier, ToPublicKey, ValidationError,
1265+
hex_script, AbsLockTime, BareCtx, Error, Legacy, RelLockTime, Satisfier, ToPublicKey,
1266+
ValidationError,
12661267
};
12671268

12681269
type Segwitv0Script = Miniscript<bitcoin::PublicKey, Segwitv0>;
@@ -1979,6 +1980,14 @@ mod tests {
19791980
);
19801981
}
19811982

1983+
#[test]
1984+
fn terminal_ord_locktimes_are_consistent() {
1985+
let a = Miniscript::<String, Segwitv0>::after(AbsLockTime::from_consensus(9).unwrap());
1986+
let b = Miniscript::<String, Segwitv0>::after(AbsLockTime::from_consensus(10).unwrap());
1987+
// Matches policy ordering: 9 < 10 under cmp_by_consensus
1988+
assert!(a < b, "after(9) < after(10) under consensus u32 ordering");
1989+
}
1990+
19821991
#[test]
19831992
fn template_timelocks() {
19841993
use crate::{AbsLockTime, RelLockTime};

src/miniscript/satisfy/mod.rs

Lines changed: 138 additions & 62 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

@@ -563,89 +565,138 @@ mod tests {
563565

564566
#[test]
565567
fn regression_895() {
566-
// Tests a pathological descriptor whose cheapest satisfaction would require mixing
567-
// timelocks, although a more expensive satisfaction is available that avoids the
568-
// timelock mixing. This descriptor is accepted by rust-miniscript but its satisfier
569-
// cannot produce a satisfaction for it.
568+
// Tests a pathological descriptor whose cheapest satisfaction involves computing a
569+
// dissatisfaction containing a timelock. Such dissatisfactions exist because the
570+
// `and_v` fragment, uniquely, has a dissatisfaction that includes the satisfaction
571+
// of its left child. (Check sat_dissat.rs and search 'dissat: ' to see how the
572+
// dissatisfactions of every fragment are computed. You will see that exactly one,
573+
// and_v, uses the satisfaction of a child.)
570574
//
571-
// Prior to PR #895, the satisfaction logic would yield the invalid timelock-mixing
572-
// satisfaction. After PR #895, it yields no satisfaction at all.
573-
//
574-
// The correct behavior is arguably to find the more expensive satisfaction. Doing
575-
// this would would require some sort of backtracking in the satisfier and is highly
576-
// nontrivial. Since triggering this bug requires generating a satisfaction in a
577-
// context where both a height-based and time-based timelock are available, an
578-
// impossible situation, it is probably not worth fixing.
575+
// Prior to PR #895, the satisfier did not keep track of timelocks that were used
576+
// for dissatisfactions. This can lead to incorrect plans, as demonstrated in the
577+
// below test vectors.
579578

580-
// Setup: an unavailable key, used to make some branches unsatisfiable from the POV
579+
// Setup: unavailable keys, used to make some branches unsatisfiable from the POV
581580
// of the satisfier (but not the typechecker).
582-
let unavailable_key = PublicKey::from_str(
583-
"02eb64639a17f7334bb5a1a3aad857d6fec65faef439db3de72f85c88bc2906ad3",
584-
)
585-
.unwrap();
581+
let available_keys: [PublicKey; 2] = [
582+
"02eb64639a17f7334bb5a1a3aad857d6fec65faef439db3de72f85c88bc2906ad1"
583+
.parse()
584+
.unwrap(),
585+
"02eb64639a17f7334bb5a1a3aad857d6fec65faef439db3de72f85c88bc2906ad3"
586+
.parse()
587+
.unwrap(),
588+
];
589+
let available_key_map = {
590+
let dummy_sig = bitcoin::ecdsa::Signature::sighash_all(
591+
secp256k1::ecdsa::Signature::from_compact(&[1; 64]).unwrap(),
592+
);
593+
let mut map = std::collections::BTreeMap::new();
594+
map.insert(
595+
crate::DefiniteDescriptorKey::new(available_keys[0].into()).unwrap(),
596+
dummy_sig,
597+
);
598+
map.insert(
599+
crate::DefiniteDescriptorKey::new(available_keys[1].into()).unwrap(),
600+
dummy_sig,
601+
);
602+
map
603+
};
604+
605+
// Generate a large pile of distinct unavailable keys.
606+
let secp = secp256k1::Secp256k1::new();
607+
let unavailable_keys: Vec<PublicKey> =
608+
core::iter::successors(Some(available_keys[0]), |prev| {
609+
prev.inner
610+
.add_exp_tweak(&secp, &secp256k1::Scalar::ONE)
611+
.ok()
612+
.map(PublicKey::new)
613+
})
614+
.skip(1)
615+
.take(20)
616+
.collect();
617+
assert_eq!(unavailable_keys.len(), 20); // sanity-check the above loop
618+
619+
// Create a fragment which is very expensive to dissatisfy.
620+
let expensive_threshold = format!(
621+
"thresh(10,pk({}),{})",
622+
unavailable_keys[1], // we need key[0] below
623+
unavailable_keys
624+
.iter()
625+
.skip(2)
626+
.map(|k| format!("a:pkh({k})"))
627+
.collect::<Vec<_>>()
628+
.join(","),
629+
);
630+
586631
// Setup: a satisfier that thinks that both a height-based and time-based timelock
587632
// are available. This is needed for the second test.
588633
let satisfier = (
634+
available_key_map,
589635
absolute::LockTime::from_height(1000).unwrap(),
590636
absolute::LockTime::from_time(2000000000).unwrap(),
591637
);
592638

593639
// Construct a script that would mix timelocks:
594640
// or_b(
595641
// n:or_i(
596-
// and_v(v:after(144),pk()), // dissatisfied by a height-based timelock
597-
// thresh(3,pk(),s:pk(),s:pk()) // dissatisfied by 3empty sigs (more expensive)
642+
// and_v(v:after(144),and_v(v:pk(),pk())), // dissatisfied by a height-based timelock, a sig, and 0
643+
// expensive_threshold // dissatisfied by a giant pile of pubkeyhash preimages
598644
// ),
599-
// sdv:after(50) // satisfied by a lower height-based timelock
645+
// ajt:and_v(v:after(50),v:pk({})))) // satisfied by a lower height-based timelock and a sig
600646
// )
601647
//
602-
// Here the or_i cannot be satisfied because none of the keys are available, so it
648+
// Here the or_i cannot be satisfied due to missing keys on both branches, so it
603649
// must be dissatisfied (and the after(50) branch must be satisfied). However, there
604650
// are two dissatisfactions for the or_i: one which dissatisfies the first branch,
605651
// by using the height-based timelock, and one which dissatisfies the second branch,
606652
// which is ignored since it's the more expensive of the two possibilities.
607653
//
608-
// We therefore take both the after(144) and after(50) branches, and the resulting
609-
// plan should show after(144) since it's the higher one. However, prior to #895,
610-
// we "did not notice" the after(144) since it appears as part of a dissatisfaction.
654+
// Since the first branch's dissatisfaction is HASSIG but the second branch's is not,
655+
// the satisfier is required to dissatisfy the second branch to avoid malleability.
656+
// But if we use `into_plan_mall` we can see the bug.
611657
//
612-
// Unrelatedly: the fact that the LHS of the `or_i` has a malleable dissatisfaction
613-
// means that the whole script is malleable, and the fact that this parses at all is
614-
// an instance of https://github.com/rust-bitcoin/rust-miniscript/issues/734
615-
658+
// Itstead, it takes both the after(144) and after(50) branches, and the resulting
659+
// plan should show after(144) since it's the higher one. However, prior to #895,
660+
// we "did not notice" the after(144) since it appears as part of a dissatisfaction,
661+
// leading to a plan that did not match the actual timelock requirement.
616662
let descriptor_str = format!(
617-
"wsh(or_b(n:or_i(and_v(v:after(144),pk({})),thresh(3,pk({}),s:pk({}),s:pk({}))),sdv:after(50)))",
618-
unavailable_key, unavailable_key, unavailable_key, unavailable_key,
663+
"wsh(or_b(n:or_i(and_v(v:after(144),and_v(v:pk({}),pk({}))),{expensive_threshold}),ajt:and_v(v:after(50),v:pk({}))))",
664+
available_keys[0], unavailable_keys[0], available_keys[1],
619665
);
620666
// Need DefiniteDescriptorKey https://github.com/rust-bitcoin/rust-miniscript/issues/927
621667
let descriptor =
622668
Descriptor::<crate::DefiniteDescriptorKey>::from_str(&descriptor_str).unwrap();
623-
// Compute plan and confirm the timelock is correct.
624-
let plan = descriptor.into_plan(&satisfier).unwrap();
669+
// Compute plan and confirm the timelock is correct -- 144 for a malleable transaction
670+
let plan = descriptor.clone().into_plan_mall(&satisfier).unwrap();
625671
assert_eq!(plan.absolute_timelock, Some(absolute::LockTime::from_height(144).unwrap()),);
672+
// ...and 50 for a non-malleable one (since take the expensive_threshold alternate)
673+
let plan = descriptor.into_plan(&satisfier).unwrap();
674+
assert_eq!(plan.absolute_timelock, Some(absolute::LockTime::from_height(50).unwrap()),);
626675

627676
// Same descriptor as above, except that now we use a time-based timelock rather than a
628-
// lower height-based one. This time the "ideal" behavior would be that once we get to
629-
// the final time-based timelock, we somehow backtrack and then use the more-expensive
630-
// dissatisfaction choice for the `or_i`. (The type system guarantees that such a choice
631-
// exists; otherwise the whole script would be flagged as mixing timelocks.)
677+
// lower height-based one.
632678
//
633-
// However, our code architecture doesn't let us backtrack like this, and it's only possible
634-
// to get into this situation if (a) you have a pathological script like this, and (b) you
635-
// call .plan() or .satisfy() with both a height-based and time-based timelock set (which
636-
// is impossible for any actual transaction). So for now we just use this unit test to
637-
// 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).
638683
let descriptor_str = format!(
639-
"wsh(or_b(n:or_i(and_v(v:after(144),pk({})),thresh(2,pk({}),s:pk({}))),sdv:after(1000000000)))",
640-
unavailable_key, unavailable_key, unavailable_key,
684+
"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({}))))",
685+
available_keys[0], unavailable_keys[0], available_keys[1],
641686
);
642687
let descriptor =
643688
Descriptor::<crate::DefiniteDescriptorKey>::from_str(&descriptor_str).unwrap();
644689

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.
645696
let plan = descriptor.into_plan(&satisfier).unwrap();
646697
assert_eq!(
647698
plan.absolute_timelock,
648-
Some(absolute::LockTime::from_time(1000000000).unwrap()),
699+
Some(absolute::LockTime::from_time(1_000_000_000).unwrap()),
649700
);
650701
}
651702
}
@@ -978,6 +1029,13 @@ pub struct Satisfaction<T> {
9781029
}
9791030

9801031
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+
9811039
/// The empty satisfaction.
9821040
///
9831041
/// This has the property that, when concatenated on either side with another satisfaction
@@ -997,11 +1055,36 @@ impl<Pk: MiniscriptKey + ToPublicKey> Satisfaction<Placeholder<Pk>> {
9971055
/// This order allows callers to write `left.concatenate_rev(right)` which feels more
9981056
/// natural than the opposite order, and more importantly, allows this method to be
9991057
/// 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.
10001064
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+
10011084
Self {
10021085
has_sig: self.has_sig || other.has_sig,
1003-
relative_timelock: cmp::max(self.relative_timelock, other.relative_timelock),
1004-
absolute_timelock: cmp::max(self.absolute_timelock, other.absolute_timelock),
1086+
relative_timelock,
1087+
absolute_timelock,
10051088
stack: Witness::combine(other.stack, self.stack),
10061089
}
10071090
}
@@ -1069,14 +1152,7 @@ impl<Pk: MiniscriptKey + ToPublicKey> Satisfaction<Placeholder<Pk>> {
10691152
// For example, the fragment thresh(2, hash, 0, 0, 0)
10701153
// is has an impossible witness
10711154
if sats[sat_indices[k - 1]].stack == Witness::Impossible {
1072-
Self {
1073-
stack: Witness::Impossible,
1074-
// If the witness is impossible, we don't care about the
1075-
// has_sig flag, nor about the timelocks
1076-
has_sig: false,
1077-
relative_timelock: None,
1078-
absolute_timelock: None,
1079-
}
1155+
Self::IMPOSSIBLE
10801156
}
10811157
// We are now guaranteed that all elements in `k` satisfactions
10821158
// are not impossible(we sort by is_impossible bool).

src/miniscript/satisfy/sat_dissat.rs

Lines changed: 1 addition & 18 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,
@@ -442,18 +435,8 @@ impl<Pk: MiniscriptKey + ToPublicKey> Satisfaction<Placeholder<Pk>> {
442435
Terminal::OrI(_, _) => {
443436
let SatDissat { dissat: r_dis, sat: r_sat } = stack.pop().unwrap();
444437
let SatDissat { dissat: l_dis, sat: l_sat } = stack.pop().unwrap();
445-
// Regarding use of `minimum_mall` for dissatisfactions: this is correct, because
446-
// dissatisfactions for individual fragments are not required to be unique. (We
447-
// track this property using the `ty.malleability.dissat` field.)
448-
//
449-
// This is because usually dissatisfactions are eventually dropped, e.g. by the
450-
// `j:` wrapper or because at the top-level a dissatisfaction is simply invalid.
451-
// In cases where non-unique dissatisfactions would cause malleability, e.g. in
452-
// the `or_c` or `or_b` fragments which use dissatisfactions as part of their
453-
// satisfactions, we set `ty.malleability.non_malleable` appropriately (and
454-
// potentially reject the whole script at creation time).
455438
SatDissat {
456-
dissat: Self::minimum_mall(
439+
dissat: min_fn(
457440
Self {
458441
stack: Witness::combine(l_dis.stack, Witness::push_1()),
459442
..l_dis

0 commit comments

Comments
 (0)