diff --git a/bip380/miniscript/fragments.py b/bip380/miniscript/fragments.py index c08473c..252d38b 100644 --- a/bip380/miniscript/fragments.py +++ b/bip380/miniscript/fragments.py @@ -5,7 +5,6 @@ See the Miniscript website for the specification of the type system: https://bitcoin.sipa.be/miniscript/. """ -import copy import bip380.miniscript.parsing as parsing from bip380.key import DescriptorKey @@ -45,7 +44,7 @@ from .errors import MiniscriptNodeCreationError from .property import Property -from .satisfaction import ExecutionInfo, Satisfaction +from .satisfaction import ExecutionInfo, Satisfaction, ThreshSubsInfo # Threshold for nLockTime: below this value it is interpreted as block number, @@ -88,7 +87,8 @@ class Node: # Whether this node does not contain a mix of timelock or heightlock of different types. # That is, not (abs_heightlocks and rel_heightlocks or abs_timelocks and abs_timelocks) no_timelock_mix = None - # Information about this Miniscript execution (satisfaction cost, etc..) + # Information about this Miniscript execution (satisfaction cost, etc..). Note that the + # execution information assumes non-malleable satisfaction. exec_info = None def __init__(self, *args, **kwargs): @@ -511,7 +511,7 @@ def satisfaction(self, sat_material): return Satisfaction.from_concat(sat_material, *self.subs) def dissatisfaction(self): - return Satisfaction.unavailable() # it's V. + return (self.subs[1].dissatisfy() + self.subs[0].satisfy()).set_non_canon() def __repr__(self): return f"and_v({','.join(map(str, self.subs))})" @@ -564,7 +564,15 @@ def satisfaction(self, sat_material): return Satisfaction.from_concat(sat_material, self.subs[0], self.subs[1]) def dissatisfaction(self): - return self.subs[1].dissatisfaction() + self.subs[0].dissatisfaction() + return ( + (self.subs[1].dissatisfaction() + self.subs[0].dissatisfaction()) + | ( + self.subs[1].dissatisfaction() + self.subs[0].satisfaction() + ).set_non_canon() + | ( + self.subs[1].satisfaction() + self.subs[0].dissatisfaction() + ).set_non_canon() + ) def __repr__(self): return f"and_b({','.join(map(str, self.subs))})" @@ -854,8 +862,10 @@ def satisfaction(self, sat_material): ) | (self.subs[2].satisfaction(sat_material) + self.subs[0].dissatisfaction()) def dissatisfaction(self): - # Dissatisfy X and Z - return self.subs[2].dissatisfaction() + self.subs[0].dissatisfaction() + # Dissatisfy X and Z [or satisfy X but dissatisfy Y] + return (self.subs[2].dissatisfaction() + self.subs[0].dissatisfaction()) | ( + self.subs[1].dissatisfaction() + self.subs[0].satisfaction() + ).set_non_canon() def __repr__(self): return f"andor({','.join(map(str, self.subs))})" @@ -945,9 +955,10 @@ def satisfaction(self, sat_material): return Satisfaction.from_thresh(sat_material, self.k, self.subs) def dissatisfaction(self): - return sum( + all_dissat = sum( [sub.dissatisfaction() for sub in self.subs], start=Satisfaction(witness=[]) ) + def __repr__(self): return f"thresh({self.k},{','.join(map(str, self.subs))})" @@ -1172,7 +1183,12 @@ def exec_info(self): return ExecutionInfo.from_wrap_dissat(self.sub.exec_info, ops_count=4, dissat=1) def dissatisfaction(self): - return Satisfaction(witness=[b""]) + sub_dissat = self.sub.dissatisfy() + return Satisfaction(witness=[b""]) | ( + sub_dissat.non_canon() + if not sub_dissat.is_unavailable() and len(sub_dissat.witness[-1]) > 0 + else Satisfaction.unavailable() + ) def __repr__(self): # Avoid duplicating colons diff --git a/bip380/miniscript/satisfaction.py b/bip380/miniscript/satisfaction.py index 67e8780..7c88fcb 100644 --- a/bip380/miniscript/satisfaction.py +++ b/bip380/miniscript/satisfaction.py @@ -25,6 +25,35 @@ def max_optional(a, b): return max(a, b) +class ThreshSubsInfo: + """Information about a thresh fragment's sub-fragments.""" + + def __init__(self, subs, sat_material): + # Fragments that cannot be satisfied with the given material. + self.unsatisfiable = [] + # Fragments that cannot be dis-satisfied with the given material. + self.undissatisfiable = [] + # The fragments that are both satisfiable and dis-satisfiable with + # the given material, along with whether they require a signature and + # the difference in size between their satisfaction and dis-satisfaction. + # These are stored inside the list so that it may be sorted to prefer + # satisfying fragments that don't require a signature and have a smaller + # satisfaction. + self.available = [] + + self.available, self.unsatisfiable, self.undissatisfiable = [], [], [] + for sub in subs: + sat, dissat = sub.satisfaction(sat_material), sub.dissatisfaction() + if sat.witness is None: + self.unsatisfiable.append(sub) + elif dissat.witness is None: + self.undissatisfiable.append(sub) + else: + self.available.append( + (int(sat.has_sig), len(sat.witness) - len(dissat.witness), sub) + ) + + class SatisfactionMaterial: """Data that may be needed in order to satisfy a Minsicript fragment.""" @@ -59,14 +88,11 @@ def __repr__(self): class Satisfaction: """All information about a satisfaction.""" - def __init__(self, witness, has_sig=False): + def __init__(self, witness, has_sig=False, non_canon=False): assert isinstance(witness, list) or witness is None self.witness = witness self.has_sig = has_sig - # TODO: we probably need to take into account non-canon sats, as the algorithm - # described on the website mandates it: - # > Iterate over all the valid satisfactions/dissatisfactions in the table above - # > (including the non-canonical ones), + self.non_canon = non_canon def __add__(self, other): """Concatenate two satisfactions together.""" @@ -114,6 +140,10 @@ def unavailable(): def is_unavailable(self): return self.witness is None + def set_non_canon(self): + self.non_canon = True + return self + def size(self): return len(self.witness) + sum(len(elem) for elem in self.witness) @@ -126,8 +156,12 @@ def from_concat(sat_material, sub_a, sub_b, disjunction=False): :param disjunction: Whether this fragment has an 'or()' semantic. """ if disjunction: - return (sub_b.dissatisfaction() + sub_a.satisfaction(sat_material)) | ( - sub_b.satisfaction(sat_material) + sub_a.dissatisfaction() + return ( + (sub_b.dissatisfaction() + sub_a.satisfaction(sat_material)) + | (sub_b.satisfaction(sat_material) + sub_a.dissatisfaction()) + | ( + sub_b.satisfaction(sat_material) + sub_a.satisfaction(sat_material) + ).set_non_canon() ) return sub_b.satisfaction(sat_material) + sub_a.satisfaction(sat_material) @@ -150,29 +184,22 @@ def from_thresh(sat_material, k, subs): :param k: The number of subs that need to be satisfied. :param subs: The list of all subs of the threshold. """ - # Pick the k sub-fragments to satisfy, prefering (in order): - # 1. Fragments that don't require a signature to be satisfied - # 2. Fragments whose satisfaction's size is smaller - # Record the unavailable (in either way) ones as we go. - arbitrage, unsatisfiable, undissatisfiable = [], [], [] - for sub in subs: - sat, dissat = sub.satisfaction(sat_material), sub.dissatisfaction() - if sat.witness is None: - unsatisfiable.append(sub) - elif dissat.witness is None: - undissatisfiable.append(sub) - else: - arbitrage.append( - (int(sat.has_sig), len(sat.witness) - len(dissat.witness), sub) - ) + thresh_info = ThreshSubsInfo(subs, sat_material) # If not enough (dis)satisfactions are available, fail. - if len(unsatisfiable) > len(subs) - k or len(undissatisfiable) > k: + if ( + len(thresh_info.unsatisfiable) > len(subs) - k + or len(thresh_info.undissatisfiable) > k + ): return Satisfaction.unavailable() # Otherwise, satisfy the k most optimal ones. - arbitrage = sorted(arbitrage, key=lambda x: x[:2]) - optimal_sat = undissatisfiable + [a[2] for a in arbitrage] + unsatisfiable + arbitrage = sorted(thresh_info.available, key=lambda x: x[:2]) + optimal_sat = ( + thresh_info.undissatisfiable + + [a[2] for a in arbitrage] + + thresh_info.unsatisfiable + ) to_satisfy = set(optimal_sat[:k]) return sum( [ @@ -343,6 +370,8 @@ def from_thresh(k, subs): # All subs are executed, there is no OP_IF branch. dyn_ops = sum([sub._dyn_ops_count for sub in subs]) + # FIXME: has_sig should be taken into account. Maybe use ThreshSubsInfo. + # In order to estimate the worst case we simulate to satisfy the k subs whose # sat/dissat ratio is the largest, and dissatisfy the others. # We do so by iterating through all the subs, recording their sat-dissat "score"