Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Fix the satisfaction algorithm to include non-canon sats #26

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions bip380/miniscript/fragments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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))})"
Expand Down Expand Up @@ -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))})"
Expand Down Expand Up @@ -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))})"
Expand Down Expand Up @@ -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))})"
Expand Down Expand Up @@ -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
Expand Down
79 changes: 54 additions & 25 deletions bip380/miniscript/satisfaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand All @@ -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(
[
Expand Down Expand Up @@ -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"
Expand Down