Skip to content
This repository was archived by the owner on Oct 22, 2026. It is now read-only.

fix(miniscript): compare thresh k and tree size in Terminal Eq/Ord - #1036

Open
portlandhodl wants to merge 2 commits into
rust-bitcoin:masterfrom
portlandhodl:fix-terminal-eq-ord-thresh
Open

portlandhodl wants to merge 2 commits into
rust-bitcoin:masterfrom
portlandhodl:fix-terminal-eq-ord-thresh

Conversation

@portlandhodl

Copy link
Copy Markdown
Collaborator

Terminal::eq never compared the k threshold or child count of Thresh fragments (they fell into the discriminant-only catch-all arm), and both Terminal::eq and Terminal::cmp zipped the two pre-order iterators without checking that they had the same length. As a result structurally distinct miniscripts could compare equal, and could order Ordering::Equal:

thresh(2,pk(A),s:pk(B)) == thresh(1,pk(A),s:pk(B))
thresh(2,pk(A),s:pk(B)) == thresh(2,pk(A),s:pk(B),s:pk(C))

while hashing differently (Hash includes k and the child count), violating the Eq/Hash and Eq/Ord contracts for Miniscript, Descriptor and TapTree, which all delegate to these impls, and corrupting sorted and hashed collections of descriptors in downstream users.

Fix: compare the number of pre-order nodes up front in both impls (a tree now orders before any tree it is a strict pre-order prefix of), and compare the Thresh threshold k and child count in eq.

Tests: regression tests cover thresh differing in k/child count, mutual Eq/Ord/Hash consistency (including antisymmetry and cross-instance equality) over a list of structurally distinct scripts, survival of distinct scripts in BTreeSet/HashSet, and descriptor-level equality/sort+dedup. All fail without the fix.

Comment thread src/miniscript/decode.rs Outdated
/// tree would compare equal to any other tree that has it as a
/// pre-order prefix, e.g. `thresh(2,A,B)` and `thresh(2,A,B,C)`.
fn eq(&self, other: &Self) -> bool {
if self.pre_order_iter().count() != other.pre_order_iter().count() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a187133:

This is super inefficient. Both these iterators are kinda costly, allocate stacks and do a bunch of accounting etc. I also don't believe it's possible for this check to fail without one of the below checks failing, since we are checking thresh.n() and every other combinator has a fixed number of children.

So we should drop this check.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack, dropped; 100% the .count() comparison was redundant. Good catch.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 2476148:

There's another node count added in Terminal::cmp which is redundant.

@apoelstra

Copy link
Copy Markdown
Member

In f3417ad:

Normally I'd complain about all this algorithmic detail in a doccomment, but in a docomment on Eq it sorta makes sense that anybody reading it is curious about how equality is implemented.

@portlandhodl
portlandhodl force-pushed the fix-terminal-eq-ord-thresh branch 2 times, most recently from 995867c to 1edab6c Compare August 24, 2026 18:39
@portlandhodl

Copy link
Copy Markdown
Collaborator Author

In f3417ad:

Normally I'd complain about all this algorithmic detail in a doccomment, but in a docomment on Eq it sorta makes sense that anybody reading it is curious about how equality is implemented.

Thought: given LLMs could easily generate the same docs, want me to just remove to keep consistency?

@apoelstra

Copy link
Copy Markdown
Member

Thought: given LLMs could easily generate the same docs, want me to just remove to keep consistency?

Yeah, I'd appreciate it. Good point that anybody curious can just ask a LLM how the comparison works (and ask whatever their "real" question is, e.g. how does it handle key equality checks or whether it uses recursion).

Terminal::eq never compared the `k` threshold or child count of
`Thresh` fragments (they fell into the discriminant-only catch-all
arm), and both Terminal::eq and Terminal::cmp zipped the two pre-order
iterators without checking that they had the same length. As a result
structurally distinct miniscripts could compare equal, and could order
Ordering::Equal:

    thresh(2,pk(A),s:pk(B)) == thresh(1,pk(A),s:pk(B))
    thresh(2,pk(A),s:pk(B)) == thresh(2,pk(A),s:pk(B),s:pk(C))

while hashing differently (Hash includes k and the child count),
violating the Eq/Hash and Eq/Ord contracts for Miniscript, Descriptor
and TapTree, which all delegate to these impls, and corrupting sorted
and hashed collections of descriptors in downstream users.

Compare the `Thresh` threshold `k` and child count `n` in
PartialEq::eq. With `n` compared there, two trees whose pre-order
yields agree must have the same number of nodes — every other
fragment has a fixed number of children — so `eq` needs no separate
length check. Ord::cmp yields only `k` for `Thresh` nodes, so it
compares the number of pre-order nodes up front; a tree now orders
before any tree it is a strict pre-order prefix of.

Assisted-by: Kimi kimi-fast-latest
Regression tests for the Terminal equality/ordering fix: structurally
distinct miniscripts and descriptors that differ only in a `thresh`
threshold `k` or child count previously compared equal and ordered
`Equal`, violating the Eq/Ord/Hash contracts and corrupting sorted
and hashed collections of descriptors.

Cover the reported failure modes permanently:

- `thresh` differing in `k` or in child count compares unequal.
- Eq, Ord and Hash are mutually consistent over a list of structurally
  distinct scripts (antisymmetry included).
- Distinct scripts survive BTreeSet/HashSet insertion while exact
  duplicates still deduplicate.
- Descriptors differing only in `thresh` policy compare unequal and
  survive sort + dedup.

Assisted-by: Kimi kimi-fast-latest
@portlandhodl
portlandhodl force-pushed the fix-terminal-eq-ord-thresh branch from 1edab6c to a9e0479 Compare August 24, 2026 18:59
@portlandhodl

Copy link
Copy Markdown
Collaborator Author

Thought: given LLMs could easily generate the same docs, want me to just remove to keep consistency?

Yeah, I'd appreciate it. Good point that anybody curious can just ask a LLM how the comparison works (and ask whatever their "real" question is, e.g. how does it handle key equality checks or whether it uses recursion).

a9e0479: Complete

@portlandhodl

portlandhodl commented Aug 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

I did some performance analysis on my host regarding the performance characteristics of this pull. This test harness was created outside this PR but should give an idea if any performance regressions would be considered unacceptable. Under normal use nothing sticks out as unacceptable in my opinion.

Performance impact

Micro-benchmark: eq/cmp on a 63-node balanced thresh miniscript (~2.5 KB script — near the wsh standardness limit), baseline d2ec504 vs this PR, both compiled into the same release+LTO binary, best-of-9 × 2000 iterations.

Benchmark environment

CPU AMD Ryzen 5 7600X (6 cores / 12 threads)
RAM 32 GB
OS Ubuntu 26.04 LTS, Linux 7.0.0-29-generic, x86_64
Toolchain rustc 1.95.0 (59807616e 2026-04-14)
Operation Before After Change Notes
eq — identical trees 2368 ns 2482 ns ~+4% noise band; new thresh arm is two O(1) integer compares
eq — differ in thresh k 2344 ns 66 ns ~35× faster now exits at the first differing node (old code walked the whole tree and returned the wrong answer)
eq — no-thresh control 2115 ns 2239 ns ~noise new match arm never hit
cmp — identical trees 4380 ns 7320 ns +67% two extra pre-order counting passes
cmp — differ at first leaf 298 ns 3209 ns ~11× O(distance) → O(n), but only +2.9 µs absolute
cmp — differ at root fragment name 6 ns 6 ns none fast path unchanged
hash control (unchanged code) 1688 ns 1683 ns ~0% harness stable
BTreeSet stress: 256 inserts of near-identical scripts 2.80 ms 4.81 ms +72% adversarial worst case, pure cmp cost

@apoelstra

Copy link
Copy Markdown
Member

Lol at the benchmarks showing massive speedups in cases where we now correctly notice that we're comparing different trees.

@apoelstra apoelstra left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK a9e0479; successfully ran local tests

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants