Skip to content

Commit 790dbfe

Browse files
committed
Tests: hypothesis invariants for matching rule 1
One property per bullet of MATCHING.md's invariants section, over a small content pool chosen for dense claim and slot collisions: the containment statement restated independently, reorder invariance across inputs, outputs, and condition lists, monotonicity (removing a claim never invalidates), the unmatchable-claim rejection, the exactly-claimed-output removal and mutation metamorphics, and the generalized theft property (k identical claims never fit k minus one identical slots). Model preconditions (value conservation, duplicate outpoints, empty sides, amount ranges) are pinned as ValueError, distinct from spend invalidity.
1 parent b1461f5 commit 790dbfe

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Property-based invariants for the matching layer.
2+
3+
The matching rules have no external oracle, so these properties and
4+
the adversarial vectors in vectors/matching/ stand in for one. Each
5+
property here is one bullet of MATCHING.md's invariants section.
6+
7+
Transactions are generated over a small content pool (three scripts,
8+
four amounts) so claim and slot collisions are dense, which is where
9+
rule 1 earns its keep.
10+
"""
11+
12+
from collections import Counter
13+
14+
import pytest
15+
from bitlisp import (
16+
BitLispError,
17+
CreateCoin,
18+
Transaction,
19+
TxInput,
20+
TxOutput,
21+
validate_transaction,
22+
)
23+
from hypothesis import given
24+
from hypothesis import strategies as st
25+
26+
SCRIPTS = (b"\x51", b"\x52", b"\x53")
27+
AMOUNTS = (0, 1, 2, 3)
28+
UNMATCHABLE = b"\xff" # never appears in generated outputs
29+
30+
contents = st.tuples(st.sampled_from(SCRIPTS), st.sampled_from(AMOUNTS))
31+
claim_lists = st.lists(contents, max_size=3)
32+
output_lists = st.lists(contents, min_size=1, max_size=6)
33+
# None is a non-BitLisp input, a list is a BitLisp input's claims.
34+
input_specs = st.lists(st.one_of(st.none(), claim_lists), min_size=1, max_size=3)
35+
36+
37+
def build_tx(input_claims, outputs):
38+
"""A transaction whose first input funds all outputs, with
39+
headroom so metamorphic amount bumps stay conserved."""
40+
out_total = sum(amount for _, amount in outputs) + 10
41+
inputs = []
42+
for n, claims in enumerate(input_claims):
43+
conditions = (
44+
None
45+
if claims is None
46+
else tuple(CreateCoin(script, amount) for script, amount in claims)
47+
)
48+
inputs.append(
49+
TxInput(
50+
txid=bytes([n + 1]) * 32,
51+
index=0,
52+
script_pubkey=b"\x51",
53+
amount=out_total if n == 0 else 0,
54+
sequence=0xFFFFFFFF,
55+
conditions=conditions,
56+
)
57+
)
58+
return Transaction(
59+
version=2,
60+
locktime=0,
61+
inputs=tuple(inputs),
62+
outputs=tuple(TxOutput(script, amount) for script, amount in outputs),
63+
)
64+
65+
66+
def is_valid(tx):
67+
try:
68+
validate_transaction(tx)
69+
return True
70+
except BitLispError as exc:
71+
assert exc.code == "unsatisfied_output_claim"
72+
return False
73+
74+
75+
def all_claims(input_claims):
76+
return [c for claims in input_claims if claims is not None for c in claims]
77+
78+
79+
@given(input_specs, output_lists)
80+
def test_validity_is_multiset_containment(input_claims, outputs):
81+
"""Rule 1's spec statement, restated independently: valid exactly
82+
when no content is claimed more times than slots carry it."""
83+
tx = build_tx(input_claims, outputs)
84+
claimed = Counter(all_claims(input_claims))
85+
slots = Counter(outputs)
86+
expected = all(count <= slots[content] for content, count in claimed.items())
87+
assert is_valid(tx) == expected
88+
89+
90+
@given(input_specs, output_lists, st.randoms(use_true_random=False))
91+
def test_reordering_never_changes_the_outcome(input_claims, outputs, rng):
92+
tx = build_tx(input_claims, outputs)
93+
shuffled_inputs = list(input_claims)
94+
rng.shuffle(shuffled_inputs)
95+
shuffled_inputs = [
96+
(None if claims is None else rng.sample(claims, len(claims)))
97+
for claims in shuffled_inputs
98+
]
99+
shuffled_outputs = rng.sample(outputs, len(outputs))
100+
shuffled = build_tx(shuffled_inputs, shuffled_outputs)
101+
assert is_valid(tx) == is_valid(shuffled)
102+
103+
104+
@given(input_specs, output_lists)
105+
def test_removing_a_condition_never_invalidates(input_claims, outputs):
106+
"""Constraints only tighten. Dropping any one claim from a valid
107+
transaction leaves it valid."""
108+
tx = build_tx(input_claims, outputs)
109+
if not is_valid(tx):
110+
return
111+
for i, claims in enumerate(input_claims):
112+
if not claims:
113+
continue
114+
for j in range(len(claims)):
115+
reduced = list(input_claims)
116+
reduced[i] = claims[:j] + claims[j + 1 :]
117+
assert is_valid(build_tx(reduced, outputs))
118+
119+
120+
@given(input_specs, output_lists)
121+
def test_adding_an_unmatchable_claim_always_invalidates(input_claims, outputs):
122+
augmented = list(input_claims)
123+
augmented.append([(UNMATCHABLE, 1)])
124+
assert not is_valid(build_tx(augmented, outputs))
125+
126+
127+
@given(input_specs, output_lists)
128+
def test_removing_an_exactly_claimed_output_invalidates(input_claims, outputs):
129+
tx = build_tx(input_claims, outputs)
130+
if not is_valid(tx) or len(outputs) < 2:
131+
return
132+
claimed = Counter(all_claims(input_claims))
133+
slots = Counter(outputs)
134+
for content, count in claimed.items():
135+
if count == slots[content]:
136+
reduced = list(outputs)
137+
reduced.remove(content)
138+
assert not is_valid(build_tx(input_claims, reduced))
139+
break
140+
141+
142+
@given(input_specs, output_lists, st.sampled_from(["amount", "script"]))
143+
def test_mutating_an_exactly_claimed_output_invalidates(
144+
input_claims, outputs, mutation
145+
):
146+
"""Metamorphic: bump the amount or flip a script byte on a slot
147+
whose content is exactly covered by claims."""
148+
tx = build_tx(input_claims, outputs)
149+
if not is_valid(tx):
150+
return
151+
claimed = Counter(all_claims(input_claims))
152+
slots = Counter(outputs)
153+
for content, count in claimed.items():
154+
if count == slots[content] and count > 0:
155+
script, amount = content
156+
if mutation == "amount":
157+
mutated_content = (script, amount + 1)
158+
else:
159+
mutated_content = (bytes([script[0] ^ 0x01]), amount)
160+
mutated = list(outputs)
161+
mutated[mutated.index(content)] = mutated_content
162+
assert not is_valid(build_tx(input_claims, mutated))
163+
break
164+
165+
166+
@given(st.integers(min_value=2, max_value=5), contents)
167+
def test_k_claims_never_fit_k_minus_1_slots(k, content):
168+
"""The theft property, generalized: k identical claims are never
169+
satisfied by k - 1 identical slots."""
170+
input_claims = [[content] for _ in range(k)]
171+
outputs = [content] * (k - 1)
172+
assert not is_valid(build_tx(input_claims, outputs))
173+
assert is_valid(build_tx(input_claims, outputs + [content]))
174+
175+
176+
# --- transaction-model preconditions ---------------------------------------
177+
# Value conservation is enforced at construction, so matching never
178+
# sees a transaction that creates value out of nothing.
179+
180+
181+
def test_model_rejects_value_creation():
182+
tx_input = TxInput(b"\x01" * 32, 0, b"\x51", 4, 0)
183+
with pytest.raises(ValueError, match="value not conserved"):
184+
Transaction(2, 0, (tx_input,), (TxOutput(b"\x51", 5),))
185+
186+
187+
def test_model_rejects_duplicate_outpoints():
188+
tx_input = TxInput(b"\x01" * 32, 0, b"\x51", 1, 0)
189+
with pytest.raises(ValueError, match="duplicate input outpoint"):
190+
Transaction(2, 0, (tx_input, tx_input), (TxOutput(b"\x51", 1),))
191+
192+
193+
def test_model_rejects_empty_sides():
194+
tx_input = TxInput(b"\x01" * 32, 0, b"\x51", 1, 0)
195+
with pytest.raises(ValueError, match="non-empty"):
196+
Transaction(2, 0, (), (TxOutput(b"\x51", 1),))
197+
with pytest.raises(ValueError, match="non-empty"):
198+
Transaction(2, 0, (tx_input,), ())
199+
200+
201+
def test_model_rejects_out_of_range_amounts():
202+
with pytest.raises(ValueError, match="amount out of range"):
203+
TxOutput(b"\x51", -1)
204+
with pytest.raises(ValueError, match="amount out of range"):
205+
TxOutput(b"\x51", 2_100_000_000_000_001)

0 commit comments

Comments
 (0)