diff --git a/docs/execution-plan.md b/docs/execution-plan.md index 63ea83c..5d591f2 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -85,7 +85,7 @@ descriptions, and commit messages* - [x] **Unit 6: macros.** Landed 2026-08-16 (PR 52) as `defmacro` with `qq` and `unquote`, classic Chialisp semantics stated in `docs/lang/macros.md`. Decisions by Evan, 2026-08-16: macros only, includes split to their own unit below (skeleton change under ground rule 6). `if` and `list` stay compiler forms, so macros cannot shadow built-ins. Expansion is depth-capped and cost-budgeted, a recorded deviation from Chialisp's unbounded expansion. No `function` or `com` reflection form, the laziness expressiveness gap recorded in the language doc. No refactor rode along, the shared emission-primitives cleanup stays a candidate for a standalone PR. Expansion runs as a source pre-pass before reachability, macro bodies compile at declaration against earlier macros only, and read-back diverges from clvm_tools by one hop only (decision by Evan, 2026-08-16, steelmanned both ways, then narrowed twice as four review rounds showed every wider evidence scheme unsound on post-reader bytes): names the caller writes in a call's own arguments error when unresolved, REPL def spellings are barred resolution-side, and capture plus stale template spellings stay as Chialisp has them, documented sharp edges. The three new reserved words are a deliberate source and symbol-file compatibility break for earlier programs that used them as names, pinned by a loader test. Compiled representatives pinned in `vectors/vm/macro-programs.json`. Kept after the 2026-08-16 public-record review (decision by Evan): the fixed special forms and capped expansion avoid the macro-built-language failure mode on that record, and re-evaluation stays open at unit 7. Reversed by unit 6c the same day. - [x] **Unit 6b: includes and the compile-time forms.** Split from unit 6 (decision by Evan, 2026-08-16): an include mechanism needs its own recorded decision against the self-containment rule, a program compiling identically pasted into the REPL. Scope expanded and resequenced ahead of unit 7 (decision by Evan, 2026-08-16, on a two-corpus census of tibetswap and chia-gaming): source-level include of shared constants and functions, computed compile-time constants, and inline functions. Every production puzzle file in both corpora imports shared definitions, and tibetswap defines nine of every ten functions inline. The census corrected the earlier deferral's premise: chia-gaming never abandoned source-level import, its compiled-sibling hash plumbing lives in its build layer outside the language, and that import stays deferred with `bitlisp-compile -T` covering the need manually in v0. `let` stays out, two uses across both corpora, both in tests. `assign` stays held on unit 7 evidence with a pre-registered trigger: benchmark-puzzle helpers that exist only to name intermediate values. Landed 2026-08-16 as `include`, computed `defconstant`, and `defun-inline`, stated in `docs/lang/language.md`. Decisions by Evan, 2026-08-16, via the approved unit plan: the self-containment rule is amended to the form plus its include files resolved through the same explicit search path everywhere, a repeat include dedupes by resolved file and a cycle errors where the classic reference dies on the collision or recurses without bound, `defconstant` evaluates its value on the reference VM under the default budget (a pinned break with unit 4's verbatim semantics, the modern defconst behavior under the classic keyword), and `defun-inline` keeps call-by-name laziness while closing classic's probe-verified sharp edges: arity checked, quoted content untouched, shadowing impossible, expansion depth-capped. Compiled representatives joined `compiled-programs.json`. - [x] **Unit 6c: the macro reversal.** Landed 2026-08-16 as the removal of `defmacro`, `qq`, and `unquote` and the addition of `assert`, `and`, and `or` as fixed compiler forms with classic utility_macros semantics, reversing unit 6 (decision by Evan, 2026-08-16, both sides steelmanned under ground rule 3). The evidence: three usage surveys (Chia's canonical 91-puzzle corpus, the corpora vendored in references/, and chia-gaming) found no novel macro in any deployed puzzle, short-circuit assert, and, and or the entire production vocabulary, and chia-gaming's production referee choosing built-in destructuring over the structural macro sitting unbuilt beside it. The reserved-word set change breaks compatibility in both directions, pinned by the loader test. Compiled representatives joined `vectors/vm/compiled-programs.json`, and `macro-programs.json` left with the feature. -- [ ] **Unit 6d: the forms two puzzles asked for** (added 2026-08-22, decision by Evan, skeleton change under ground rule 6): `let` with several bindings, compiled as the naming helpers are written by hand, `list*` consing its arguments onto a tail, the list library completed, and a shared puzzle test harness. Evidence: the eight authoring observations in `docs/puzzles/singleton.md`. Declared widths for curried values stay on the typed v1 ledger. Sequenced after the identity-assert rework and before the payment pool, so the remaining puzzles are written with the forms rather than rewritten. +- [x] **Unit 6d: the forms two puzzles asked for** (added 2026-08-22, decision by Evan, skeleton change under ground rule 6): `let` with several bindings, compiled as the naming helpers are written by hand, `list*` consing its arguments onto a tail, the list library completed, and a shared puzzle test harness. Evidence: the eight authoring observations in `docs/puzzles/singleton.md`. Declared widths for curried values stay on the typed v1 ledger. Sequenced after the identity-assert rework and before the payment pool, so the remaining puzzles are written with the forms rather than rewritten. Landed 2026-08-23: `let` compiles as the one-shot naming helper with parallel bindings matching the modern Chialisp dialect, `list*` is a recorded tooling divergence, `append`, `reverse`, `sum`, and `last` complete `puzzles/lib/list.blib`, and `python/tests/puzzle_support.py` owns the shared harness. Compiled representatives joined `compiled-programs.json`, and both puzzles' bytes did not move. - [ ] **Unit 7: the four benchmark puzzles, written in the language, as the acceptance artifact:** vault, payment pool, async offer, singleton wrapper. This phase makes them exist and run, Phase 4 measures them. One PR per puzzle where size warrants. Style constraint (decision by Evan, 2026-08-16, simplified by unit 6c, widened by unit 6b's resequencing): the puzzles are plain functions, the fixed forms, and the unit 6b surfaces, there being no macro system, and the authoring experience feeds the typed v1 gate's ledger note on whether unit 6c's cut ever binds. Vault first, resequenced ahead of the pool (decision by Evan, 2026-08-16): core semantics match BIP-345, one PR, plus the keyless leader/follower consolidation path over the message ledger with its theft vectors, the evaluation doc's section 3.2 benchmark. Recovery posture is a curried per-instance choice, keyless or keyed (decision by Evan, 2026-08-16). The vault's dispatch helpers exist only to name a reconstructed root once, the pre-registered assign trigger firing, evidence for the unit 7 ledger note. Singleton wrapper second, landed 2026-08-22 (PR 59), resequenced ahead of the pool and the offer: the Chia shape does not port, because a txid commits input outpoints but not input scripts and no VM operator performs the taproot tweak, so the lineage scriptPubKey is constant per launch and the state rides in a tagged OP_RETURN of the creating transaction, read back through two transaction preimages. The constant scriptPubKey is the built shape, both sides stated in `docs/puzzles/singleton.md` (decision by Evan, 2026-08-22, on the census below), and the taproot-tweak operator condition-record decision 3 declined is flagged for the Phase 4 gate with this puzzle as the case that answers decision 3's objection. The helpers that name a txid and an inner hash once are the assign trigger firing again. Value question answered 2026-08-22 in that doc (census of the vendored Chia corpora plus the Bitcoin constructions): Ark, Lightning, vaults, and pools get identity from the root outpoint and need no singleton, the lineage machinery is what the fungible asset token needs, and the remaining lineage effort belongs there. The token is a capability-and-risk study, not a goal: BitLisp does not want fungible asset tokens on Bitcoin, it wants to know what the vocabulary can build and what that exposes (decision by Evan, 2026-08-22). The name BAT1 applies if a standard is ever written. Eight authoring observations across the vault and the singleton are recorded there for the ledger. The fungible asset token joins the unit as a capability-and-risk study (decision by Evan, 2026-08-22): not a goal, built on the singleton's lineage machinery to know what the vocabulary can build and what it exposes, BAT1 only if a standard is ever written. Remaining order: the identity-assert rework of both landed puzzles, unit 6d, then the payment pool, the async offer, and the token study. **Standing constraints:** diff --git a/docs/glossary.md b/docs/glossary.md index 841e3ec..6b8292e 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -64,6 +64,8 @@ A term enters this table in the same PR that introduces it. | curry, uncurry | compare filling the placeholder keys of a script template before deriving its address | curry, uncurry (clvm_tools, chia_rs) | fixing values into a program to make a new committed program and reading them back out, the shape and the strict uncurry contract in docs/lang/curry.md, landed 2026-08-16 | | include | no script equivalent, script has no authoring language | include (clvm_tools stage 2), import (clvm_tools_rs) | the declaration-file splice form, one declaration list per file resolved through the -I search path, files deduplicated and cycles rejected where classic errors or recurses (docs/lang/language.md deviations), landed 2026-08-16 | | defun-inline | no script equivalent, script has no authoring language | defun-inline (Chialisp) | the inline function declaration, call-by-name splicing at each call site with arity checked and quoted content untouched where classic substitutes blindly (docs/lang/language.md deviations), landed 2026-08-16 | +| let | no script equivalent, script has no authoring language | let (the modern Chialisp dialect) | parallel bindings for one body expression, compiled as a one-shot apply in place of the hand-written naming helper (docs/lang/language.md), landed 2026-08-23 | +| list* | no script equivalent, script has no authoring language | none, a recorded deviation | list ending in its final argument as the tail instead of nil, for condition lists extending an inherited tail (docs/lang/language.md deviations), landed 2026-08-23 | | mod hash | compare the script inside a P2WSH or tapleaf commitment, identified by its hash | MOD_HASH, the curried-in hash of a puzzle's own uncurried source | the tree hash of an uncurried program, curried into instances so a program can recompute its own curried identity (docs/puzzles/vault.md), glossed 2026-08-16 with the vault | | vault | BIP-345 vault, OP_VAULT plus OP_VAULT_RECOVER | the clawback and custody puzzles are the nearest relatives | the first benchmark puzzle, delayed sends with an always-available recovery path (docs/puzzles/vault.md), landed 2026-08-16 | | trigger spend | the OP_VAULT trigger transaction | no direct equivalent | the authorized vault spend committing a withdrawal target chosen at spend time and creating the triggered coin | diff --git a/docs/lang/curry.md b/docs/lang/curry.md index d318290..9b6ffab 100644 --- a/docs/lang/curry.md +++ b/docs/lang/curry.md @@ -70,13 +70,17 @@ artifact. The uncurry direction is deliberately stricter, in line with preferring to reject valid-looking input, and a test pins the divergence against the wheel. -One structural collision is worth knowing. A compiled program that -declares functions has exactly this shape, the main expression -applied over the quoted function tree, so uncurry reports it as a -curry of one value, and that value is the function tree. Uncurry -proves shape, not history: it cannot tell a program somebody -curried from a program that merely compiles to the same tree, and -a zero exit status is never evidence that currying happened. +Two structural collisions are worth knowing. A compiled program +that declares functions has exactly this shape, the main +expression applied over the quoted function tree, so uncurry +reports it as a curry of one value, and that value is the +function tree. A top-level `let` binding only literals, in a +program with no function tree, emits the shape as well, the +quoted body applied over the quoted values, which uncurry reports +as curried values. Uncurry proves shape, not history: it cannot +tell a program somebody curried from a program that merely +compiles to the same tree, and a zero exit status is never +evidence that currying happened. ## Tree hash as identity diff --git a/docs/lang/language.md b/docs/lang/language.md index bd273d1..b470d4a 100644 --- a/docs/lang/language.md +++ b/docs/lang/language.md @@ -11,7 +11,8 @@ name is exactly a token the raw reader rejects as an unknown symbol, so strings, hex, operator names, and decimals keep their raw spelling rules, and the language occupies only text that previously errored. The reserved words are `program`, `defun`, `defun-inline`, -`defconstant`, `include`, `if`, `list`, `assert`, `and`, and `or`. +`defconstant`, `include`, `if`, `let`, `list`, `list*`, `assert`, +`and`, and `or`. ## The program form @@ -200,6 +201,20 @@ Builds a proper list of its evaluated arguments, folding into `c` calls: `(list A B)` compiles as `(c A (c B ()))`, and `(list)` is nil. Condition output is written with it. +## list* + +``` +(list* * ) +``` + +Builds a list of its evaluated arguments consed onto the last one, +which becomes the tail: `(list* A B T)` compiles as +`(c A (c B T))`. Where `list` always ends in nil, `list*` is how a +condition list extends a tail the program inherited. With a single +operand there is nothing to cons and the tail compiles bare. +`(list*)` is rejected at compile time: "list* takes items and a +final tail". + ## assert ``` @@ -248,6 +263,41 @@ evaluate, and nil when every operand is falsy. `(or)` is nil. `(and X)` and `(or X)` compile to the same tree, both meaning X as a boolean. +## let + +``` +(let (( )*) ) +``` + +Binds names to evaluated expressions for one body expression. +Bindings are parallel: every expression evaluates in the enclosing +scope, and the bound names are visible only in the body, so a +binding referencing a sibling is an unknown name. Sequential +naming is nested `let`, each layer seeing the ones above. A +binding names one value, with no destructuring shape. Bound names +follow the parameter rules: duplicates in one binding list are +rejected, a name may not be a reserved word or a condition +constant, and inside the body a bound name shadows parameters, +functions, and constants, exactly as a parameter does. Every name +the `let` does not bind stays visible in the body. + +`let` compiles as the naming helpers it replaces are written by +hand, a function taking the bound names as parameters and called +once, except that the body is applied in place instead of entering +the function tree: + +``` +(a (q . ) (c 2 (c ... 3))) +``` + +The body's environment is the enclosing one with the bound values +consed in front of the arguments, so the function tree stays at +path 2, calls and recursion inside the body work unchanged, and +the cost is one apply plus one cons per binding, the price of the +hand-written helper call. In a program with no function tree the +rebuild drops the tree cons. `(let () )` binds nothing and +its body compiles bare. + ## Expressions and quoting An expression is an atom, a name, or a form. @@ -288,7 +338,9 @@ as a proper list: Every function therefore sees the same environment shape, its own parameters rooted at path 3, which is why recursion and mutual -recursion fall out of the layout. The whole program is emitted as +recursion fall out of the layout. A `let` rebuilds the layout the +same way, its bound values consed in front of the arguments, so a +body under `let` still sees the tree at path 2. The whole program is emitted as ``` (a (q .
) (c (q . ) 1)) @@ -345,7 +397,9 @@ and shows the source name and the live arguments by parameter name instead of raw bytecode. A function whose compiled body is a single atom stays out of the table, because an atom's hash cannot be told apart from ordinary data. Inline functions stay out too: spliced -code has no one compiled body to hash. `main_params` records the +code has no one compiled body to hash. A `let` body stays out as +well: it is part of the expression that contains it, not a +function of its own. `main_params` records the program's own parameter names for the reader. ## Deviations from Chialisp @@ -368,9 +422,14 @@ nothing forces a change. The deliberate differences: compile time rather than at run time. - `if`, `list`, `assert`, `and`, and `or` are compiler forms, not macros, with the semantics Chialisp's stage-2 and utility_macros - macros give the same spellings. Nothing can shadow them: where - Chialisp's newest macro silently wins, the one-namespace rule - makes redefinition an error. + macros give the same spellings, and `let` is a compiler form + with the modern dialect's parallel binding semantics. Nothing + can shadow any of them: where Chialisp's newest macro silently + wins, the one-namespace rule makes redefinition an error. +- `list*` is an addition with no Chialisp counterpart: no dialect + has a form that builds a list onto a tail, so condition lists + extending an inherited tail are consed by hand there. The + expansion is one `c` per item, purely syntactic. - There is no macro system. Chialisp's `defmacro`, `qq`, and `unquote` are omitted, a removal decided on production evidence: across Chia's deployed puzzle corpus and its largest application diff --git a/puzzles/lib/list.blib b/puzzles/lib/list.blib index 0353ef8..0571b14 100644 --- a/puzzles/lib/list.blib +++ b/puzzles/lib/list.blib @@ -11,4 +11,26 @@ ; The element at INDEX, raising past the end of the list. (defun nth (ITEMS INDEX) (if INDEX (nth (r ITEMS) (- INDEX 1)) (f ITEMS))) + + ; LEFT's items in order, then RIGHT as the tail as given, so an + ; improper tail survives. + (defun append (LEFT RIGHT) + (if LEFT (c (f LEFT) (append (r LEFT) RIGHT)) RIGHT)) + + ; The accumulator helper owns its generic name under the + ; one-namespace rule, as every name in this file does. + (defun reverse-onto (ITEMS ONTO) + (if ITEMS (reverse-onto (r ITEMS) (c (f ITEMS) ONTO)) ONTO)) + + (defun reverse (ITEMS) + (reverse-onto ITEMS ())) + + ; The integer sum of the items, 0 for the empty list. + (defun sum (ITEMS) + (if ITEMS (+ (f ITEMS) (sum (r ITEMS))) 0)) + + ; The final item, raising on the empty list as nth raises past + ; the end. + (defun last (ITEMS) + (if (r ITEMS) (last (r ITEMS)) (f ITEMS))) ) diff --git a/python/bitlisp_tools/compiler.py b/python/bitlisp_tools/compiler.py index 0d340b6..b359c02 100644 --- a/python/bitlisp_tools/compiler.py +++ b/python/bitlisp_tools/compiler.py @@ -6,7 +6,8 @@ language occupies only text that previously errored. Atoms quote themselves, names resolve to environment paths or inline constant values, and the special forms are program, defun, defun-inline, -defconstant, include, if, list, assert, and, and or. Everything +defconstant, include, if, let, list, list*, assert, and, and or. +Everything else a source expression can say is an operator application. A compiled program's environment is the pair (function tree . args). @@ -45,7 +46,7 @@ from bitlisp.errors import BitLispError from bitlisp.machine import run from bitlisp.operators import OPERATORS -from bitlisp.sexp import NIL, int_to_atom, is_atom, is_pair +from bitlisp.sexp import NIL, atom_to_int, int_to_atom, is_atom, is_pair from .keywords import ATOM_TO_NAME from .printer import _atom_text @@ -70,7 +71,9 @@ _DEFCONSTANT, _INCLUDE, _IF, + _LET, _LIST, + _LIST_STAR, _ASSERT, _AND, _OR, @@ -81,7 +84,9 @@ "defconstant", "include", "if", + "let", "list", + "list*", "assert", "and", "or", @@ -94,7 +99,9 @@ _DEFCONSTANT, _INCLUDE, _IF, + _LET, _LIST, + _LIST_STAR, _ASSERT, _AND, _OR, @@ -548,6 +555,15 @@ def _proper_list(*nodes): return result +def _cons_onto(compiled, tail): + """The cons chain the two list forms share: each compiled item + in order, ending in tail, nil for a proper list.""" + result = tail + for item in reversed(compiled): + result = _proper_list(_CONS, item, result) + return result + + def _lazy_if(condition, then_branch, else_branch): """The lazy branch idiom every branching form compiles through. The VM's i evaluates all three arguments, so the branches @@ -668,8 +684,12 @@ def _named_form(self, head, tail, bindings): name = head.name if name == _IF: return self._if(head, tail, bindings) + if name == _LET: + return self._let(head, tail, bindings) if name == _LIST: return self._list(head, tail, bindings) + if name == _LIST_STAR: + return self._list_star(head, tail, bindings) if name == _ASSERT: return self._assert(head, tail, bindings) if name == _AND: @@ -699,10 +719,18 @@ def _if(self, head, tail, bindings): def _list(self, head, tail, bindings): items = _proper_items(tail, _LIST, head.offset) - result = NIL - for item in reversed(items): - result = _proper_list(_CONS, self.expression(item, bindings), result) - return result + compiled = [self.expression(item, bindings) for item in items] + return _cons_onto(compiled, NIL) + + def _list_star(self, head, tail, bindings): + # The last argument is the tail the others cons onto, so + # the built list ends in it instead of nil, and a lone + # tail compiles bare. + items = _proper_items(tail, _LIST_STAR, head.offset) + if not items: + raise CompileError("list* takes items and a final tail", head.offset) + compiled = [self.expression(item, bindings) for item in items] + return _cons_onto(compiled[:-1], compiled[-1]) def _assert(self, head, tail, bindings): items = _proper_items(tail, _ASSERT, head.offset) @@ -736,6 +764,106 @@ def _or(self, head, tail, bindings): result = _lazy_if(condition, _TRUE, result) return result + def _let(self, head, tail, bindings): + """The bound names become parameters of the body, applied + once in place: the environment is rebuilt as the enclosing + one with the bound values consed in front of the arguments, + so the function tree keeps its path and calls inside the + body work unchanged. The cost is one apply plus one cons + per binding, the hand-written naming helper's price. + + Every enclosing binding is re-rooted under the rebuilt + environment's tail. The rewrite leans on two invariants, + the first enforced below: with a function tree every + binding path descends the argument side, path 3, because + parameters never bind into the tree at path 2, and a + compiled expression that is a non-nil atom is always an + environment path, because value atoms quote themselves + into pairs. A quote-headed binding evaluates the same + under any environment and passes through unchanged. Any + other pair-valued binding, an inline call-by-name + substitution, is an arbitrary expression meaningful only + in the enclosing environment, so each reference re-applies + it against that environment rebuilt from the tail, keeping + the inline contract: used twice evaluates twice, unused + never evaluates.""" + items = _proper_items(tail, _LET, head.offset) + if len(items) != 2: + raise CompileError("let takes bindings and a body", head.offset) + entries = [] + node = items[0] + while is_pair(node): + # A bare name in entry position is the classic unnested + # spelling (let (A 1) ...), so the error names the shape + # instead of complaining about the argument list. + entry = _proper_items(node[0], "a let binding") if is_pair(node[0]) else [] + if len(entry) != 2 or not isinstance(entry[0], Symbol): + symbol = first_symbol(node[0]) + raise CompileError( + "a let binding takes a name and a value", + head.offset if symbol is None else symbol.offset, + ) + entries.append(entry) + node = node[1] + if node != NIL: + offset = node.offset if isinstance(node, Symbol) else head.offset + raise CompileError("let takes a binding list", offset) + if not entries: + return self.expression(items[1], bindings) + has_tree = bool(self.fn_paths) + # With a tree the old arguments sit at path 3 of the old + # environment and the bound names root at path 3 of the new + # one. Bare, both are the whole environment, path 1. + root = _RIGHT if has_tree else _TOP + bound_paths = {} + values = [] + tail_path = root + for symbol, value in entries: + name = _check_name(symbol, "binding") + if name in bound_paths: + raise CompileError(f"duplicate binding {name!r}", symbol.offset) + values.append(self.expression(value, bindings)) + bound_paths[name] = int_to_atom(_compose(tail_path, _LEFT)) + tail_path = _compose(tail_path, _RIGHT) + rebound = {} + for name, bound in bindings.items(): + if bound == NIL: + rebound[name] = bound + elif is_atom(bound): + path = atom_to_int(bound) + if has_tree: + if path < _RIGHT or not path & 1: + # The enforced invariant: a bare atom that + # does not descend the argument side is not + # a binding path, and rewriting it would + # miscompile, so refuse loudly instead. + raise AssertionError( + f"binding path {path} is not under the arguments" + ) + # Drop the leading rest step: the path continues + # from the old arguments, which the tail reaches. + path >>= 1 + rebound[name] = int_to_atom(_compose(tail_path, path)) + elif is_atom(bound[0]) and bound[0] == _QUOTE: + # A quoted node is environment independent. + rebound[name] = bound + else: + if has_tree: + environment = _proper_list( + _CONS, int_to_atom(_LEFT), int_to_atom(tail_path) + ) + else: + environment = int_to_atom(tail_path) + rebound[name] = _proper_list(_APPLY, _quote(bound), environment) + rebound.update(bound_paths) + body = self.expression(items[1], rebound) + rest = int_to_atom(root) + for value in reversed(values): + rest = _proper_list(_CONS, value, rest) + if has_tree: + rest = _proper_list(_CONS, int_to_atom(_LEFT), rest) + return _proper_list(_APPLY, _quote(body), rest) + def _inline_call(self, head, tail, bindings): """The body compiled at the call site, parameter references replaced by the arguments' compiled expressions. Nothing diff --git a/python/tests/puzzle_support.py b/python/tests/puzzle_support.py new file mode 100644 index 0000000..ea77653 --- /dev/null +++ b/python/tests/puzzle_support.py @@ -0,0 +1,201 @@ +"""Shared harness for the benchmark puzzle test files. + +A plain module beside support.py for the same reason support.py is +one: importing helpers by name from conftest works only through +conftest's special loading. It holds the puzzle-agnostic pieces: +the transaction and input builders, the outpoint-bound signer, the +curried-instance derivation, the run wrappers, the spec-rule root +reimplementation, and the vector-agreement audits. Everything a +puzzle owns (sources, mod hashes, lifecycle, case tables) stays in +that puzzle's test file, and the mod-hash drift guards there keep +proving a harness change touches no puzzle bytes. + +The vendored Bitcoin Core framework signer joins sys.path here, so +a puzzle file gets it by importing this module first. +""" + +import hashlib +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT / "tools" / "oracle" / "bitcoincore")) + +from bitlisp import BitLispError, Transaction, TxInput, run, serialize # noqa: E402 +from bitlisp.conditions import parse_conditions # noqa: E402 +from bitlisp.secp256k1 import taproot_output_key # noqa: E402 +from bitlisp.sexp import NIL # noqa: E402 +from bitlisp_tools import assemble # noqa: E402 +from bitlisp_tools.compiler import tree_hash # noqa: E402 +from bitlisp_tools.curry import curry # noqa: E402 +from bitlisp_tools.runner import run_spend # noqa: E402 +from support import NUMS, condition_inputs, load_vector # noqa: E402 +from test_framework.key import sign_schnorr # noqa: E402 + +BUDGET = 11_000_000_000 +SEQ_FINAL = 0xFFFFFFFF +AUX = b"\x00" * 32 +FEE_SPK = bytes.fromhex("0014") + b"\x11" * 20 + + +def proper_list(*items): + node = NIL + for item in reversed(items): + node = (item, node) + return node + + +def outpoint(txid, index): + return txid + index.to_bytes(4, "little") + + +def tx_fields(tx): + """The transaction in the field shape tx-wire.blib serializes.""" + return proper_list( + tx.version.to_bytes(4, "little"), + proper_list( + *[ + proper_list( + outpoint(i.txid, i.index), + i.script_sig, + i.sequence.to_bytes(4, "little"), + ) + for i in tx.inputs + ] + ), + proper_list( + *[ + proper_list(o.amount.to_bytes(8, "little"), o.script_pubkey) + for o in tx.outputs + ] + ), + tx.locktime.to_bytes(4, "little"), + ) + + +def tx(inputs, outputs): + return Transaction( + version=2, locktime=0, inputs=tuple(inputs), outputs=tuple(outputs) + ) + + +def instance(node, values, internal_key=NUMS): + """A curried instance, its merkle root, and its scriptPubKey.""" + inst = curry(node, values) + root = tree_hash(inst) + return inst, root, b"\x51\x20" + taproot_output_key(internal_key, root) + + +def sig_my_outpoint(sk, txid, index, message_node): + """The outpoint-bound Schnorr signature ASSERT_SIG_MY_OUTPOINT + verifies, over the tagged digest of the consumed outpoint and + the message's tree hash, with fixed aux bytes so every derived + signature is deterministic.""" + tag = hashlib.sha256(b"BitLisp/sig/my_outpoint").digest() + digest = hashlib.sha256( + tag + tag + outpoint(txid, index) + tree_hash(message_node) + ).digest() + return sign_schnorr(sk, digest, AUX) + + +def fee_input(amount, txid=b"\xbb" * 32, index=0): + return TxInput(txid, index, FEE_SPK, amount, sequence=SEQ_FINAL) + + +def taproot_input( + txid, + index, + spk, + amount, + root, + internal_key=NUMS, + sequence=SEQ_FINAL, + conditions=None, +): + """A BitLisp input of a single-leaf taproot tree, where the + executing leaf's hash is also the spending path's merkle root.""" + return TxInput( + txid, + index, + spk, + amount, + sequence=sequence, + conditions=conditions, + tapleaf=root, + merkle_root=root, + internal_key=internal_key, + ) + + +def _node(solution): + return assemble(solution) if isinstance(solution, str) else solution + + +def spend_error(program, solution, spending, input_index=0): + with pytest.raises(BitLispError) as info: + run_spend(program, _node(solution), spending, input_index) + return info.value.code + + +def run_error(program, solution): + with pytest.raises(BitLispError) as info: + run(program, _node(solution), BUDGET) + return info.value.code + + +def conditions_of(program, solution): + _, result = run(program, _node(solution), BUDGET) + _, conds = parse_conditions(result, None) + return conds + + +def emitted_hex(program, solution): + return serialize(run(program, _node(solution), BUDGET)[1]).hex() + + +def _h_atom(data): + return hashlib.sha256(b"\x01" + data).digest() + + +def _h_pair(left, right): + return hashlib.sha256(b"\x02" + left + right).digest() + + +def spec_curried_root(mod_hash, values): + """The curried instance's tree hash from the spec's two hash + rules alone, independent of tree_hash and of the puzzle helpers: + the curried shape is (a (q . F) (c (q . v1) ... 1)) with apply + the atom 2, quote and the chain terminator the atom 1, and cons + the atom 4. All curried values here are atoms.""" + chain = _h_atom(b"\x01") + for value in reversed(values): + quoted = _h_pair(_h_atom(b"\x01"), _h_atom(value)) + chain = _h_pair(_h_atom(b"\x04"), _h_pair(quoted, _h_pair(chain, _h_atom(b"")))) + program = _h_pair(_h_atom(b"\x01"), mod_hash) + return _h_pair(_h_atom(b"\x02"), _h_pair(program, _h_pair(chain, _h_atom(b"")))) + + +def assert_vm_vectors_match(name, expected): + """Every pinned program, and solution where one is given, in the + named vm vector file must be byte-identical to its fresh + construction from source, the case sets equal both ways, so the + corpus cannot drift from the sources.""" + cases = load_vector(name) + assert set(cases) == set(expected) + for case_name, (program, solution) in expected.items(): + assert cases[case_name]["program"] == serialize(program).hex(), case_name + if solution is not None: + assert cases[case_name]["env"] == serialize(solution).hex(), case_name + + +def assert_conditions_closure(names, expected): + """The complete closure over the named validation vector files: + every conditions field a file carries is recomputed by the + caller and every recomputed payload appears, set equality in + both directions, so a drifted payload fails and so does a case + the caller's table does not account for.""" + files = [load_vector(name) for name in names] + observed = {entry["conditions"] for _, entry in condition_inputs(files)} + assert observed == expected diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index 328d3eb..8002235 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -149,6 +149,49 @@ def test_pin_list_and_nil(): assert result == assemble("(1 () ())") +def test_pin_list_star_shapes(): + # A lone tail has nothing to cons and compiles bare, and each + # further item adds one cons, the built list ending in the tail + # instead of nil. + program, _, _ = _run_program("(program (T) (list* T))", "((5 6))") + assert disassemble(program) == "2" + program, _, result = _run_program("(program (A B T) (list* A B T))", "(1 2 (5 6))") + assert disassemble(program) == "(c 2 (c 5 11))" + assert result == assemble("(1 2 5 6)") + + +def test_pin_let_shapes(): + # Bare: the body is quoted and applied onto the bound value + # consed in front of the old environment, so A reads path 2 and + # the tail is the whole old environment, path 1. With a + # function tree the rebuild keeps the tree at path 2 and X, + # path 5 outside the let, reads path 11 through the tail. + # Empty bindings compile the body bare. + program, _, result = _run_program( + "(program (X) (let ((A (+ X 1))) (* A A)))", "(4)" + ) + assert disassemble(program) == "(a (q 18 2 2) (c (+ 2 (q . 1)) 1))" + assert result == int_to_atom(25) + program, _, result = _run_program( + "(program (X) (defun double (N) (* 2 N)) (let ((A (double X))) (+ A A X)))", + "(5)", + ) + assert disassemble(program) == ( + "(a (q 2 (q 16 5 5 11) (c 2 (c (a 2 (c 2 (c 5 ()))) 3))) " + "(c (q 18 (q . 2) 5) 1))" + ) + assert result == int_to_atom(25) + program, _, _ = _run_program("(program (X) (let () (+ X 1)))", "(9)") + assert disassemble(program) == "(+ 2 (q . 1))" + # Two bindings: the second name reads one rest step deeper, and + # the old environment lands behind both consed values. + program, _, result = _run_program( + "(program (X Y) (let ((A X) (B Y)) (c A B)))", "(7 9)" + ) + assert disassemble(program) == "(a (q 4 2 5) (c 2 (c 5 1)))" + assert result == (int_to_atom(7), int_to_atom(9)) + + def test_pin_constant_inlines(): program, _, result = _run_program( "(program (AMT) (defconstant FEE 500) (- AMT FEE))", "(1200)" @@ -250,6 +293,115 @@ def test_and_or_are_boolean_and_lazy(): assert result == int_to_atom(1) +def test_list_star_extends_an_inherited_tail(): + # The shape condition lists take when a program conses its own + # conditions onto a tail it was handed. + source = """(program (SCRIPT AMT TAIL) + (list* (list CREATE_OUTPUT SCRIPT AMT) TAIL))""" + _, _, result = _run_program(source, "(0xaa 700 ((81 1)))") + assert result == assemble("((1 0xaa 700) (81 1))") + + +def test_let_binds_in_parallel_and_nests(): + # Parallel: every value evaluates against the enclosing scope, + # so a swap through a let works, and sequential naming is the + # nested-let idiom. + _, _, result = _run_program( + "(program (X Y) (let ((X Y) (Y X)) (list X Y)))", "(1 2)" + ) + assert result == assemble("(2 1)") + source = """(program (X) + (let ((A (+ X 1))) + (let ((B (* A 2))) (list A B X))))""" + _, _, result = _run_program(source, "(10)") + assert result == assemble("(11 22 10)") + + +def test_let_shadows_and_keeps_the_rest_of_scope(): + # A bound name shadows a parameter, a function, or a constant + # exactly as a parameter does, and every unshadowed name stays + # visible through the rebuilt environment, dotted parameters + # included. + _, _, result = _run_program("(program (X) (let ((X (* X X))) (+ X 1)))", "(6)") + assert result == int_to_atom(37) + source = """(program (X Y) + (defconstant K 100) + (defun bump (N) (+ N 1)) + (let ((bump (bump X)) (K (+ K Y))) (list bump K)))""" + _, _, result = _run_program(source, "(5 2)") + assert result == assemble("(6 102)") + _, _, result = _run_program( + "(program (H . T) (let ((A (f T))) (list A H)))", "(1 2 3)" + ) + assert result == assemble("(2 1)") + + +def test_let_body_calls_through_the_tree(): + # The rebuilt environment keeps the function tree at path 2, so + # recursion inside a let body needs no special handling. + source = """(program (N) + (defun down (N) (if (= N 0) () (c N (down (- N 1))))) + (let ((K (+ N 1))) (down K)))""" + _, _, result = _run_program(source, "(3)") + assert result == assemble("(4 3 2 1)") + + +def test_let_inside_an_inline_body_keeps_call_by_name(): + # The inline argument stays call-by-name through the rebuilt + # environment: used twice it evaluates twice, and unused it + # never evaluates, so the raise never fires. + source = """(program (X) + (defun-inline twice (V) (let ((A 1)) (+ V V A))) + (twice (* X X)))""" + _, _, result = _run_program(source, "(3)") + assert result == int_to_atom(19) + source = """(program (X) + (defun-inline drop (V) (let ((A 7)) A)) + (drop (x)))""" + _, _, result = _run_program(source, "(1)") + assert result == int_to_atom(7) + + +def test_let_passes_quoted_inline_bindings_through(): + # A quoted node evaluates the same under any environment, so a + # literal-valued inline substitution crosses a let unwrapped + # instead of paying a restore-apply per reference. + source = """(program (X) + (defun-inline plus (V) (let ((A 3)) (+ V A))) + (plus 5))""" + program, _, result = _run_program(source, "()") + assert result == int_to_atom(8) + text = disassemble(program) + assert "(q . 5)" in text + assert "(a (q 1 . 5)" not in text + + +def test_let_inside_a_defconstant_value(): + source = """(program () + (defconstant K (let ((A (+ 20 1))) (* A 2))) + K)""" + _, _, result = _run_program(source) + assert result == int_to_atom(42) + + +def test_let_names_an_expensive_value_once(): + # Naming through let evaluates the value once where the inline + # substitution evaluates it per reference, so past a small + # value the apply plus cons overhead costs less than the + # second evaluation. + named = "(program (X) (let ((H (sha256tree X))) (list H H)))" + inlined = """(program (X) + (defun-inline pair (H) (list H H)) + (pair (sha256tree X)))""" + solution = "((1 2 3 4 5 6 7 8))" + named_program, _, named_result = _run_program(named, solution) + inlined_program, _, inlined_result = _run_program(inlined, solution) + assert named_result == inlined_result + named_cost = run(named_program, assemble(solution), BUDGET)[0] + inlined_cost = run(inlined_program, assemble(solution), BUDGET)[0] + assert named_cost < inlined_cost + + def test_destructured_parameters(): source = "(program ((A B) C) (+ A (* B C)))" program, _, _ = _run_program(source, "((2 3) 10)") @@ -421,7 +573,7 @@ def test_load_symbols_tracks_the_reserved_words(): _, table = compile_program("(program (X) (defun fun (N) (* 2 N)) (fun X))") data = symbols_to_json(table) (key,) = data["functions"] - for name in ("assert", "and", "or", "include", "defun-inline"): + for name in ("assert", "and", "or", "include", "defun-inline", "let", "list*"): data["functions"][key]["name"] = name with pytest.raises(ValueError) as excinfo: load_symbols(data) @@ -836,7 +988,29 @@ def test_included_body_error_names_function_and_file(tmp_path): ("(program (X) (defun f N) X)", "defun takes 3 parts"), ("(program (X) (defconstant K) X)", "defconstant takes 2 parts"), ("(program (X) (assert))", "assert takes conditions and a final value"), + ("(program (X) (list*))", "list* takes items and a final tail"), + ("(program (X) (list* 1 . 2))", "list* takes a proper argument list"), ("(program (assert) 1)", "'assert' is a reserved word"), + ("(program (list*) 1)", "'list*' is a reserved word"), + ("(program (X) (defun list* (N) N) X)", "'list*' is a reserved word"), + ("(program (X) (let))", "let takes bindings and a body"), + ("(program (X) (let ((A 1))))", "let takes bindings and a body"), + ("(program (X) (let ((A 1) . B) A))", "let takes a binding list"), + ("(program (X) (let ((A)) A))", "a let binding takes a name and a value"), + ("(program (X) (let (A 1) A))", "a let binding takes a name and a value"), + ("(program (X) (let ((A 1 2)) A))", "a let binding takes a name and a value"), + ( + "(program (X) (let ((A 1 . 2)) A))", + "a let binding takes a proper argument list", + ), + ("(program (X) (let ((A 1) (A 2)) A))", "duplicate binding 'A'"), + ("(program (X) (let ((list 1)) 2))", "'list' is a reserved word"), + ("(program (X) (let ((SEAL 1)) 2))", "'SEAL' is a condition constant"), + ("(program (X) (let ((A X)) (A 1)))", "'A' is a parameter, not a function"), + ("(program (X) (let ((A B)) A))", "unknown name 'B'"), + ("(program (X) (let ((A 1) (B A)) B))", "unknown name 'A'"), + ("(program (let) 1)", "'let' is a reserved word"), + ("(program (X) (defun let (N) N) X)", "'let' is a reserved word"), ("(program (X) (defun and (N) N) X)", "'and' is a reserved word"), ("(program (X) (defconstant or 1) X)", "'or' is a reserved word"), ("(program (X) (defun include (N) N) X)", "'include' is a reserved word"), @@ -925,7 +1099,9 @@ def test_reserved_words_are_pinned_and_all_dispatch(): "defconstant", "include", "if", + "let", "list", + "list*", "assert", "and", "or", @@ -964,6 +1140,43 @@ def test_list_builds_the_literal_list(values): assert result == expected +@given( + st.lists(st.integers(min_value=0, max_value=2**63 - 1), max_size=6), + st.lists(st.integers(min_value=0, max_value=2**63 - 1), max_size=4), +) +def test_list_star_conses_onto_its_tail(values, tail_values): + # Against Python's own cons fold: the built list is the items + # ended by the quoted tail rather than nil. + defs = Definitions() + tail = NIL + for value in reversed(tail_values): + tail = (int_to_atom(value), tail) + text = "(list* {} (q . {}))".format( + " ".join(str(value) for value in values), disassemble(tail) + ) + program, _ = compile_expression(text, defs) + cost, result = run(program, NIL, BUDGET) + expected = tail + for value in reversed(values): + expected = (int_to_atom(value), expected) + assert result == expected + + +@given(st.lists(st.integers(min_value=0, max_value=2**63 - 1), max_size=5)) +def test_let_sums_its_bound_names(values): + # Random integer bindings, the body summing every bound name, + # against Python's own sum. + defs = Definitions() + names = [f"V{i}" for i in range(len(values))] + bound = " ".join( + f"({name} {value})" for name, value in zip(names, values, strict=True) + ) + text = "(let ({}) (+ {}))".format(bound, " ".join(names)) + program, _ = compile_expression(text, defs) + cost, result = run(program, NIL, BUDGET) + assert result == int_to_atom(sum(values)) + + @given(st.lists(st.integers(min_value=0, max_value=3), max_size=6)) def test_and_or_match_all_and_any(values): # The boolean value semantics against Python's own fold, empty diff --git a/python/tests/test_puzzle_lib.py b/python/tests/test_puzzle_lib.py new file mode 100644 index 0000000..4a116fa --- /dev/null +++ b/python/tests/test_puzzle_lib.py @@ -0,0 +1,117 @@ +"""List library tests: each helper against Python's own fold, the +edge cases, and the pruning pin that keeps library growth free for +programs reaching none of it.""" + +import sys +from pathlib import Path + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(REPO_ROOT / "python")) + +from bitlisp import run, serialize # noqa: E402 +from bitlisp.errors import BitLispError # noqa: E402 +from bitlisp.sexp import NIL, int_to_atom # noqa: E402 +from bitlisp_tools import assemble # noqa: E402 +from bitlisp_tools.compiler import compile_program # noqa: E402 + +BUDGET = 11_000_000_000 +INCLUDES = (str(REPO_ROOT / "puzzles" / "lib"),) + + +def _run(source, solution_text): + program, _ = compile_program(source, include_paths=INCLUDES) + cost, result = run(program, assemble(solution_text), BUDGET) + return result + + +def _one_list(body, values): + source = f'(program (ITEMS) (include "list.blib") {body})' + solution = "(({}))".format(" ".join(str(value) for value in values)) + return _run(source, solution) + + +def _proper(values): + node = NIL + for value in reversed(values): + node = (int_to_atom(value), node) + return node + + +_values = st.lists(st.integers(min_value=0, max_value=2**63 - 1), max_size=8) +_nonempty = st.lists( + st.integers(min_value=0, max_value=2**63 - 1), min_size=1, max_size=6 +) + + +@given(_values) +def test_length_matches_len(values): + assert _one_list("(length ITEMS)", values) == int_to_atom(len(values)) + + +@given(_nonempty) +def test_nth_matches_indexing(values): + for index, value in enumerate(values): + assert _one_list(f"(nth ITEMS {index})", values) == int_to_atom(value) + + +@given(_values, _values) +def test_append_matches_concatenation(left, right): + source = '(program (L R) (include "list.blib") (append L R))' + solution = "(({}) ({}))".format( + " ".join(str(value) for value in left), + " ".join(str(value) for value in right), + ) + assert _run(source, solution) == _proper(left + right) + + +@given(_values) +def test_reverse_matches_reversed(values): + assert _one_list("(reverse ITEMS)", values) == _proper(list(reversed(values))) + + +@given(_values) +def test_sum_matches_sum(values): + assert _one_list("(sum ITEMS)", values) == int_to_atom(sum(values)) + + +@given(_nonempty) +def test_last_matches_the_final_item(values): + assert _one_list("(last ITEMS)", values) == int_to_atom(values[-1]) + + +def test_append_keeps_an_improper_tail(): + # RIGHT is the tail as given, the list* shape a program uses + # when it conses its conditions onto an inherited tail. + source = '(program (L T) (include "list.blib") (append L T))' + assert _run(source, "((1 2) 5)") == assemble("(1 2 . 5)") + + +def test_empty_list_edges(): + assert _one_list("(length ITEMS)", []) == int_to_atom(0) + assert _one_list("(sum ITEMS)", []) == int_to_atom(0) + assert _one_list("(reverse ITEMS)", []) == NIL + + +def test_last_and_nth_raise_past_the_end(): + with pytest.raises(BitLispError): + _one_list("(last ITEMS)", []) + with pytest.raises(BitLispError): + _one_list("(nth ITEMS 3)", [1, 2, 3]) + + +def test_growth_is_free_for_a_length_only_program(): + # The compiled bytes of a program reaching only length, pinned + # before this unit's helpers joined the library: unreached + # declarations prune, so library growth cannot move any + # existing program's bytes. + source = '(program (ITEMS) (include "list.blib") (length ITEMS))' + program, _ = compile_program(source, include_paths=INCLUDES) + assert serialize(program).hex() == ( + "ff02ffff01ff02ff02ffff04ff02ffff04ff05ff80808080ffff04ffff01ff02ffff03" + "ff05ffff01ff10ffff0101ffff02ff02ffff04ff02ffff04ffff06ff0580ff80808080" + "80ffff018080ff0180ff018080" + ) diff --git a/python/tests/test_singleton_puzzles.py b/python/tests/test_singleton_puzzles.py index e07d27c..a322380 100644 --- a/python/tests/test_singleton_puzzles.py +++ b/python/tests/test_singleton_puzzles.py @@ -13,50 +13,48 @@ fixed aux bytes, so every derived value is deterministic. """ -import hashlib import sys from pathlib import Path -import pytest - REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT / "python")) -sys.path.insert(0, str(REPO_ROOT / "tools" / "oracle" / "bitcoincore")) - -from bitlisp import ( # noqa: E402 - BitLispError, - Transaction, - TxInput, - TxOutput, - run, - secp256k1, - serialize, -) -from bitlisp.conditions import CreateOutput, parse_conditions # noqa: E402 + +from bitlisp import Transaction, TxInput, TxOutput, run # noqa: E402 +from bitlisp.conditions import CreateOutput # noqa: E402 from bitlisp.sexp import NIL, int_to_atom, iter_proper_list # noqa: E402 from bitlisp_tools.compiler import compile_program, tree_hash # noqa: E402 from bitlisp_tools.curry import curry, uncurry # noqa: E402 from bitlisp_tools.runner import run_spend # noqa: E402 -from support import ( # noqa: E402 - NUMS, - assert_corpus_identities, - condition_inputs, - load_vector, +from puzzle_support import ( # noqa: E402 + BUDGET, + FEE_SPK, + SEQ_FINAL, + assert_conditions_closure, + assert_vm_vectors_match, + conditions_of, + emitted_hex, + fee_input, + instance, + outpoint, + proper_list, + run_error, + sig_my_outpoint, + spend_error, + taproot_input, + tx, + tx_fields, ) -from test_framework.key import compute_xonly_pubkey, sign_schnorr # noqa: E402 +from support import NUMS, assert_corpus_identities, load_vector # noqa: E402 +from test_framework.key import compute_xonly_pubkey # noqa: E402 PUZZLES = REPO_ROOT / "puzzles" INCLUDES = (PUZZLES / "lib", PUZZLES / "singleton") -BUDGET = 11_000_000_000 -SEQ_FINAL = 0xFFFFFFFF OWNER_SK = (0xC0 << 248 | 11).to_bytes(32, "big") OWNER_PK = compute_xonly_pubkey(OWNER_SK)[0] OWNER2_SK = (0xC1 << 248 | 12).to_bytes(32, "big") OWNER2_PK = compute_xonly_pubkey(OWNER2_SK)[0] -FEE_SPK = bytes.fromhex("0014") + b"\x11" * 20 PAY_SPK = bytes.fromhex("0014") + b"\x22" * 20 -AUX = b"\x00" * 32 END_AMOUNT = -113 END_PAYLOAD = b"\x00" * 32 @@ -78,41 +76,6 @@ ) -def proper_list(*items): - node = NIL - for item in reversed(items): - node = (item, node) - return node - - -def outpoint(txid, index): - return txid + index.to_bytes(4, "little") - - -def tx_fields(tx): - """The transaction in the field shape tx-wire.blib serializes.""" - return proper_list( - tx.version.to_bytes(4, "little"), - proper_list( - *[ - proper_list( - outpoint(i.txid, i.index), - i.script_sig, - i.sequence.to_bytes(4, "little"), - ) - for i in tx.inputs - ] - ), - proper_list( - *[ - proper_list(o.amount.to_bytes(8, "little"), o.script_pubkey) - for o in tx.outputs - ] - ), - tx.locktime.to_bytes(4, "little"), - ) - - def state_payload(inner_hash, amount, index): return tree_hash(proper_list(inner_hash, int_to_atom(amount), int_to_atom(index))) @@ -130,17 +93,7 @@ def state_output(launcher, inner_hash, amount, index): def lineage(launcher): """A lineage's curried wrapper, its merkle root, and its scriptPubKey.""" - inst = curry(SINGLETON_NODE, [SINGLETON_MOD_HASH, launcher]) - root = tree_hash(inst) - return inst, root, b"\x51\x20" + secp256k1.taproot_output_key(NUMS, root) - - -def sig_my_outpoint(sk, txid, index, message_node): - tag = hashlib.sha256(b"BitLisp/sig/my_outpoint").digest() - digest = hashlib.sha256( - tag + tag + outpoint(txid, index) + tree_hash(message_node) - ).digest() - return sign_schnorr(sk, digest, AUX) + return instance(SINGLETON_NODE, [SINGLETON_MOD_HASH, launcher]) def inner_solution(sk, txid, index, next_hash, next_amount, tx, extra=NIL, sig=None): @@ -176,32 +129,6 @@ def solution( ) -def fee_input(txid=b"\xbb" * 32, amount=50_000, index=0): - return TxInput(txid, index, FEE_SPK, amount, sequence=SEQ_FINAL) - - -def singleton_input(spk, root, txid, index, amount, conditions=None): - """A BitLisp input of a single-leaf taproot tree, where the - executing leaf's hash is also the spending path's merkle root.""" - return TxInput( - txid, - index, - spk, - amount, - sequence=SEQ_FINAL, - conditions=conditions, - tapleaf=root, - merkle_root=root, - internal_key=NUMS, - ) - - -def tx(inputs, outputs): - return Transaction( - version=2, locktime=0, inputs=tuple(inputs), outputs=tuple(outputs) - ) - - LAUNCHER_TXID = b"\xaa" * 32 LAUNCHER = outpoint(LAUNCHER_TXID, 0) SINGLETON, ROOT, SPK = lineage(LAUNCHER) @@ -226,7 +153,7 @@ def tx(inputs, outputs): # Generation 1: the first coin hands the lineage to the second owner, # child at output 0 again. TX1 = tx( - [singleton_input(SPK, ROOT, LAUNCH.txid, 0, AMOUNT), fee_input()], + [taproot_input(LAUNCH.txid, 0, SPK, AMOUNT, ROOT), fee_input(50_000)], [ TxOutput(SPK, AMOUNT), state_output(LAUNCHER, H2, AMOUNT, 0), @@ -241,7 +168,7 @@ def tx(inputs, outputs): # Generation 2: the singleton sits at input 1 and its child at output # 1, and the second owner hands the lineage back. TX2 = tx( - [fee_input(b"\xcc" * 32), singleton_input(SPK, ROOT, TX1.txid, 0, AMOUNT)], + [fee_input(50_000, b"\xcc" * 32), taproot_input(TX1.txid, 0, SPK, AMOUNT, ROOT)], [ TxOutput(FEE_SPK, 40_000), TxOutput(SPK, AMOUNT), @@ -257,7 +184,7 @@ def tx(inputs, outputs): # the ending state output. PAYOUT = proper_list(proper_list(b"\x01", PAY_SPK, int_to_atom(10_000))) TX3 = tx( - [singleton_input(SPK, ROOT, TX2.txid, 1, AMOUNT)], + [taproot_input(TX2.txid, 1, SPK, AMOUNT, ROOT)], [TxOutput(PAY_SPK, 10_000), TxOutput(state_script(LAUNCHER, END_PAYLOAD), 0)], ) SOL3 = solution( @@ -281,9 +208,9 @@ def tx(inputs, outputs): # output set. COMPOSED = tx( [ - singleton_input(SPK, ROOT, LAUNCH.txid, 0, AMOUNT), - singleton_input(OTHER_SPK, OTHER_ROOT, OTHER_LAUNCH.txid, 0, AMOUNT), - fee_input(), + taproot_input(LAUNCH.txid, 0, SPK, AMOUNT, ROOT), + taproot_input(OTHER_LAUNCH.txid, 0, OTHER_SPK, AMOUNT, OTHER_ROOT), + fee_input(50_000), ], [ TxOutput(SPK, AMOUNT), @@ -311,8 +238,8 @@ def tx(inputs, outputs): [TxInput(b"\xee" * 32, 3, FEE_SPK, 100_000, sequence=SEQ_FINAL)], [TxOutput(SPK, AMOUNT), state_output(LAUNCHER, H1, AMOUNT, 0)], ) -UNRELATED = tx([fee_input(b"\xed" * 32, 400_000)], [TxOutput(FEE_SPK, 100_000)] * 4) -GRANDPARENT_OF_FEE = tx([fee_input(b"\xdd" * 32, 60_000)], [TxOutput(FEE_SPK, 60_000)]) +UNRELATED = tx([fee_input(400_000, b"\xed" * 32)], [TxOutput(FEE_SPK, 100_000)] * 4) +GRANDPARENT_OF_FEE = tx([fee_input(60_000, b"\xdd" * 32)], [TxOutput(FEE_SPK, 60_000)]) CHAINED = tx( [TxInput(GRANDPARENT_OF_FEE.txid, 0, FEE_SPK, 60_000, sequence=SEQ_FINAL)], [TxOutput(SPK, AMOUNT), state_output(LAUNCHER, H1, AMOUNT, 0)], @@ -324,7 +251,7 @@ def tx(inputs, outputs): # coin of the observer's own at the committed index, the authorized # child moved to index 3. SUBSTITUTED = tx( - [singleton_input(SPK, ROOT, LAUNCH.txid, 0, AMOUNT), fee_input()], + [taproot_input(LAUNCH.txid, 0, SPK, AMOUNT, ROOT), fee_input(50_000)], [ TxOutput(SPK, 1), state_output(LAUNCHER, H2, AMOUNT, 0), @@ -335,7 +262,7 @@ def tx(inputs, outputs): # The ending spend re-assembled with a refill: a 1-satoshi coin and a # second tagged output naming the observer's inner program. REFILLED = tx( - [singleton_input(SPK, ROOT, TX2.txid, 1, AMOUNT), fee_input()], + [taproot_input(TX2.txid, 1, SPK, AMOUNT, ROOT), fee_input(50_000)], TX3.outputs + (TxOutput(SPK, 1), state_output(LAUNCHER, H2, 1, 2)), ) @@ -365,24 +292,6 @@ def ending_spend_sealing(spending): ) # fmt: skip -def spend_error(program, sol, spending, input_index=0): - with pytest.raises(BitLispError) as info: - run_spend(program, sol, spending, input_index) - return info.value.code - - -def run_error(program, sol): - with pytest.raises(BitLispError) as info: - run(program, sol, BUDGET) - return info.value.code - - -def conditions_of(program, sol): - _, result = run(program, sol, BUDGET) - _, conds = parse_conditions(result, None) - return conds - - def test_mod_hashes_pinned(): assert tree_hash(SINGLETON_NODE) == SINGLETON_MOD_HASH assert tree_hash(INNER_NODE) == OWNER_INNER_MOD_HASH @@ -417,7 +326,7 @@ def test_wire_serialization_matches_model(): Transaction( version=1, locktime=500_000, - inputs=(legacy, fee_input()), + inputs=(legacy, fee_input(50_000)), outputs=( TxOutput(b"\x6a" + b"\x55" * (width - 1), 0), TxOutput(FEE_SPK, 59_000), @@ -426,7 +335,7 @@ def test_wire_serialization_matches_model(): ) samples.append( tx( - [fee_input(bytes([i]) * 32, 1_000) for i in range(253)], + [fee_input(1_000, bytes([i]) * 32) for i in range(253)], [TxOutput(FEE_SPK, 1_000)], ) ) @@ -463,7 +372,7 @@ def test_fee_input_prepended_keeps_spend_valid(): # Placement is committed by the state output and the owner's # seal, never by the input index, so anyone may prepend a fee # input to the broadcast spend with the owner's witness unchanged. - bumped = tx([fee_input(b"\xc9" * 32)] + list(TX1.inputs), TX1.outputs) + bumped = tx([fee_input(50_000, b"\xc9" * 32)] + list(TX1.inputs), TX1.outputs) run_spend(SINGLETON, SOL1, bumped, 1) @@ -535,7 +444,7 @@ def test_ended_lineage_cannot_be_refilled(): def test_wrong_outpoint_fails_in_validator(): wrong = tx( - [singleton_input(SPK, ROOT, TX1.txid, 2, AMOUNT), fee_input()], TX2.outputs + [taproot_input(TX1.txid, 2, SPK, AMOUNT, ROOT), fee_input(50_000)], TX2.outputs ) assert spend_error(SINGLETON, SOL2, wrong) == "unsatisfied_outpoint_assert" @@ -612,7 +521,7 @@ def test_owner_signature_binds_outpoint_and_message(): (TX1.outputs[0], state_output(LAUNCHER, H1, AMOUNT, 0), TX1.outputs[2]), ) later = tx( - [singleton_input(SPK, ROOT, committed.txid, 0, AMOUNT), fee_input()], + [taproot_input(committed.txid, 0, SPK, AMOUNT, ROOT), fee_input(50_000)], TX1.outputs, ) replayed = solution(AMOUNT, 0, 0, committed, 0, 0, LAUNCH, INNER1, args1) @@ -630,8 +539,13 @@ def test_two_lineages_compose(): with_other = tx( [ COMPOSED.inputs[0], - singleton_input( - OTHER_SPK, OTHER_ROOT, OTHER_LAUNCH.txid, 0, AMOUNT, other_conds + taproot_input( + OTHER_LAUNCH.txid, + 0, + OTHER_SPK, + AMOUNT, + OTHER_ROOT, + conditions=other_conds, ), COMPOSED.inputs[2], ], @@ -775,16 +689,7 @@ def test_vm_vectors_match_source(): # Every pinned program and solution is byte-identical to a fresh # compile, curry, and lifecycle construction, so the corpus # cannot drift from the sources. - cases = load_vector("vm/singleton-programs.json") - expected = vm_cases() - assert set(cases) == set(expected) - for name, (program, sol) in expected.items(): - assert cases[name]["program"] == serialize(program).hex(), name - assert cases[name]["env"] == serialize(sol).hex(), name - - -def emitted_hex(program, sol): - return serialize(run(program, sol, BUDGET)[1]).hex() + assert_vm_vectors_match("vm/singleton-programs.json", vm_cases()) def validation_conditions(): @@ -809,9 +714,9 @@ def test_validation_vectors_match_source(): # The complete closure: every conditions field in the validation # vector file is recomputed here from compiled source and the # documented signature flip, set-equal in both directions. - files = [load_vector("validation/singleton-lineage.json")] - observed = {entry["conditions"] for _, entry in condition_inputs(files)} - assert observed == validation_conditions() + assert_conditions_closure( + ("validation/singleton-lineage.json",), validation_conditions() + ) def test_validation_vector_identities_derive_their_scripts(): diff --git a/python/tests/test_vault_puzzles.py b/python/tests/test_vault_puzzles.py index 32b63c4..867ed53 100644 --- a/python/tests/test_vault_puzzles.py +++ b/python/tests/test_vault_puzzles.py @@ -15,48 +15,41 @@ import sys from pathlib import Path -import pytest - REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT / "python")) -sys.path.insert(0, str(REPO_ROOT / "tools" / "oracle" / "bitcoincore")) - -from bitlisp import ( # noqa: E402 - BitLispError, - Transaction, - TxInput, - TxOutput, - run, - secp256k1, - serialize, -) -from bitlisp.conditions import parse_conditions # noqa: E402 + +from bitlisp import Transaction, TxOutput, serialize # noqa: E402 from bitlisp.sexp import int_to_atom, iter_proper_list # noqa: E402 from bitlisp_tools import assemble # noqa: E402 from bitlisp_tools.compiler import compile_program, tree_hash # noqa: E402 -from bitlisp_tools.curry import curry, uncurry # noqa: E402 +from bitlisp_tools.curry import uncurry # noqa: E402 from bitlisp_tools.runner import run_spend # noqa: E402 -from support import ( # noqa: E402 - NUMS, - assert_corpus_identities, - condition_inputs, - load_vector, +from puzzle_support import ( # noqa: E402 + FEE_SPK, + assert_conditions_closure, + assert_vm_vectors_match, + conditions_of, + emitted_hex, + fee_input, + instance, + run_error, + sig_my_outpoint, + spec_curried_root, + spend_error, + taproot_input, ) -from test_framework.key import compute_xonly_pubkey, sign_schnorr # noqa: E402 +from support import NUMS, assert_corpus_identities, load_vector # noqa: E402 +from test_framework.key import compute_xonly_pubkey # noqa: E402 PUZZLES = REPO_ROOT / "puzzles" INCLUDES = (PUZZLES / "lib", PUZZLES / "vault") -BUDGET = 11_000_000_000 -SEQ_FINAL = 0xFFFFFFFF AUTH_SK = (0xA0 << 248 | 7).to_bytes(32, "big") AUTH_PK = compute_xonly_pubkey(AUTH_SK)[0] RKEY_SK = (0xB0 << 248 | 9).to_bytes(32, "big") RKEY_PK = compute_xonly_pubkey(RKEY_SK)[0] RECOVERY_SPK = bytes.fromhex("0014") + b"\x99" * 20 -FEE_SPK = bytes.fromhex("0014") + b"\x11" * 20 DELAY = 144 -AUX = b"\x00" * 32 # Drift guards: recompiling the sources must reproduce these hashes, # so any source change is a deliberate re-pin of both literals and @@ -74,13 +67,6 @@ ) -def instance(node, values): - """A curried instance, its merkle root, and its scriptPubKey.""" - inst = curry(node, values) - root = tree_hash(inst) - return inst, root, b"\x51\x20" + secp256k1.taproot_output_key(NUMS, root) - - def vault_values(recovery_key): return [ VAULT_MOD_HASH, @@ -110,14 +96,6 @@ def trig_values(recovery_key, target): TRIG, TROOT, TSPK = instance(TRIG_NODE, trig_values(b"", TARGET)) TXID = b"\xaa" * 32 -FEE_TXID = b"\xbb" * 32 - - -def sig_my_outpoint(sk, txid, index, message_node): - tag = hashlib.sha256(b"BitLisp/sig/my_outpoint").digest() - outpoint = txid + index.to_bytes(4, "little") - digest = hashlib.sha256(tag + tag + outpoint + tree_hash(message_node)).digest() - return sign_schnorr(sk, digest, AUX) def trigger_solution(sk, txid, index, target, trig_amt, revault_amt, my_amt): @@ -128,69 +106,15 @@ def trigger_solution(sk, txid, index, target, trig_amt, revault_amt, my_amt): ) -def fee_input(amount=20_000): - return TxInput(FEE_TXID, 0, FEE_SPK, amount, sequence=SEQ_FINAL) - - -def bl_input(txid, index, spk, amount, root, sequence=SEQ_FINAL, conditions=None): - """A BitLisp input of a single-leaf taproot tree, where the - executing leaf's hash is also the spending path's merkle root.""" - return TxInput( - txid, - index, - spk, - amount, - sequence=sequence, - conditions=conditions, - tapleaf=root, - merkle_root=root, - internal_key=NUMS, - ) - - -def spend_error(program, solution, tx, input_index=0): - with pytest.raises(BitLispError) as info: - run_spend(program, solution, tx, input_index) - return info.value.code - - -def conditions_of(program, solution_text): - _, result = run(program, assemble(solution_text), BUDGET) - _, conds = parse_conditions(result, None) - return conds - - def test_mod_hashes_pinned(): assert tree_hash(VAULT_NODE) == VAULT_MOD_HASH assert tree_hash(TRIG_NODE) == TRIG_MOD_HASH -def _h_atom(data): - return hashlib.sha256(b"\x01" + data).digest() - - -def _h_pair(left, right): - return hashlib.sha256(b"\x02" + left + right).digest() - - -def spec_curried_root(mod_hash, values): - """The curried instance's tree hash from the spec's two hash - rules alone, independent of tree_hash and of the puzzle helpers: - the curried shape is (a (q . F) (c (q . v1) ... 1)) with apply - the atom 2, quote and the chain terminator the atom 1, and cons - the atom 4. All curried values here are atoms.""" - chain = _h_atom(b"\x01") - for value in reversed(values): - quoted = _h_pair(_h_atom(b"\x01"), _h_atom(value)) - chain = _h_pair(_h_atom(b"\x04"), _h_pair(quoted, _h_pair(chain, _h_atom(b"")))) - program = _h_pair(_h_atom(b"\x01"), mod_hash) - return _h_pair(_h_atom(b"\x02"), _h_pair(program, _h_pair(chain, _h_atom(b"")))) - - def test_curried_identity_matches_tooling(): # Three independent computations of each instance's identity # must agree: the tooling's tree_hash over the curried node, the - # spec-rule reimplementation above, and the program's own + # shared spec-rule reimplementation, and the program's own # reconstruction read off the taptree assert it emits. uncurry # must also read back the inner program and the fixed values. for node, inst, root, values in ( @@ -212,7 +136,7 @@ def test_trigger_spend_with_revault(): tx = Transaction( version=2, locktime=0, - inputs=(bl_input(TXID, 0, VSPK, 60_000, VROOT), fee_input()), + inputs=(taproot_input(TXID, 0, VSPK, 60_000, VROOT), fee_input(20_000)), outputs=(TxOutput(TSPK, 40_000), TxOutput(VSPK, 20_000)), ) _, conds = run_spend(VAULT, solution, tx) @@ -223,17 +147,7 @@ def test_trigger_spend_with_revault(): def test_trigger_value_shortfall_raises(): solution = trigger_solution(AUTH_SK, TXID, 0, TARGET, 30_000, 0, 60_000) - with pytest.raises(BitLispError) as info: - run(VAULT, solution, BUDGET) - assert info.value.code == "user_raise" - - -def path_raises(program, solution): - if isinstance(solution, str): - solution = assemble(solution) - with pytest.raises(BitLispError) as info: - run(program, solution, BUDGET) - return info.value.code + assert run_error(VAULT, solution) == "user_raise" def test_trigger_amount_guards(): @@ -245,9 +159,9 @@ def test_trigger_amount_guards(): solution = trigger_solution( AUTH_SK, TXID, 0, TARGET, trig_amt, revault_amt, 60_000 ) - assert path_raises(VAULT, solution) == "user_raise" + assert run_error(VAULT, solution) == "user_raise" short_target = trigger_solution(AUTH_SK, TXID, 0, b"\x77" * 31, 60_000, 0, 60_000) - assert path_raises(VAULT, short_target) == "user_raise" + assert run_error(VAULT, short_target) == "user_raise" def test_malformed_instance_fails_every_path(): @@ -276,7 +190,7 @@ def test_malformed_instance_fails_every_path(): ) for bad in bad_instances: for solution in solutions: - assert path_raises(bad, solution) == "user_raise" + assert run_error(bad, solution) == "user_raise" overlong_delay = instance( VAULT_NODE, [ @@ -289,7 +203,7 @@ def test_malformed_instance_fails_every_path(): int_to_atom(65_536), ], )[0] - assert path_raises(overlong_delay, f"(3 0x{VSPK.hex()})") == "user_raise" + assert run_error(overlong_delay, f"(3 0x{VSPK.hex()})") == "user_raise" nil_recovery_spk = instance( VAULT_NODE, [ @@ -303,7 +217,7 @@ def test_malformed_instance_fails_every_path(): ], )[0] for solution in solutions: - assert path_raises(nil_recovery_spk, solution) == "user_raise" + assert run_error(nil_recovery_spk, solution) == "user_raise" padded_delay = instance( VAULT_NODE, [ @@ -316,7 +230,7 @@ def test_malformed_instance_fails_every_path(): b"\x00\x00\x90", ], )[0] - assert path_raises(padded_delay, f"(3 0x{VSPK.hex()})") == "user_raise" + assert run_error(padded_delay, f"(3 0x{VSPK.hex()})") == "user_raise" bad_triggered = instance( TRIG_NODE, [ @@ -328,7 +242,7 @@ def test_malformed_instance_fails_every_path(): TARGET, ], )[0] - assert path_raises(bad_triggered, "(1)") == "user_raise" + assert run_error(bad_triggered, "(1)") == "user_raise" def test_trigger_signature_binds_target(): @@ -345,7 +259,7 @@ def test_trigger_signature_binds_target(): tx = Transaction( version=2, locktime=0, - inputs=(bl_input(TXID, 0, VSPK, 60_000, VROOT), fee_input()), + inputs=(taproot_input(TXID, 0, VSPK, 60_000, VROOT), fee_input(20_000)), outputs=(TxOutput(other_tspk, 60_000),), ) assert spend_error(VAULT, redirected, tx) == "unsatisfied_sig_assert" @@ -358,7 +272,7 @@ def test_trigger_signature_binds_outpoint(): tx = Transaction( version=2, locktime=0, - inputs=(bl_input(b"\xcd" * 32, 0, VSPK, 60_000, VROOT), fee_input()), + inputs=(taproot_input(b"\xcd" * 32, 0, VSPK, 60_000, VROOT), fee_input(20_000)), outputs=(TxOutput(TSPK, 60_000),), ) assert spend_error(VAULT, solution, tx) == "unsatisfied_sig_assert" @@ -391,7 +305,10 @@ def test_full_lifecycle(): trigger_tx = Transaction( version=2, locktime=0, - inputs=(bl_input(*vault_outpoint, VSPK, 100_000, VROOT), fee_input()), + inputs=( + taproot_input(*vault_outpoint, VSPK, 100_000, VROOT), + fee_input(20_000), + ), outputs=(TxOutput(trig_spk, 100_000),), ) run_spend(VAULT, solution, trigger_tx) @@ -401,7 +318,7 @@ def withdrawal(sequence): version=2, locktime=0, inputs=( - bl_input( + taproot_input( trigger_tx.txid, 0, trig_spk, 100_000, trig_root, sequence=sequence ), ), @@ -420,7 +337,7 @@ def withdrawal(sequence): with_fee = Transaction( version=2, locktime=0, - inputs=matured.inputs + (fee_input(),), + inputs=matured.inputs + (fee_input(20_000),), outputs=matured.outputs, ) run_spend(trig_inst, assemble("(1)"), with_fee) @@ -436,7 +353,10 @@ def withdrawal(sequence): recovery_from_vault = Transaction( version=2, locktime=0, - inputs=(bl_input(*vault_outpoint, VSPK, 100_000, VROOT), fee_input()), + inputs=( + taproot_input(*vault_outpoint, VSPK, 100_000, VROOT), + fee_input(20_000), + ), outputs=(TxOutput(RECOVERY_SPK, 100_000),), ) run_spend(VAULT, assemble("(2 100000 100000)"), recovery_from_vault) @@ -444,8 +364,8 @@ def withdrawal(sequence): version=2, locktime=0, inputs=( - bl_input(trigger_tx.txid, 0, trig_spk, 100_000, trig_root), - fee_input(), + taproot_input(trigger_tx.txid, 0, trig_spk, 100_000, trig_root), + fee_input(20_000), ), outputs=(TxOutput(RECOVERY_SPK, 100_000),), ) @@ -463,7 +383,7 @@ def keyed_recovery_tx(): return Transaction( version=2, locktime=0, - inputs=(bl_input(TXID, 0, KVSPK, 60_000, KVROOT), fee_input()), + inputs=(taproot_input(TXID, 0, KVSPK, 60_000, KVROOT), fee_input(20_000)), outputs=(TxOutput(RECOVERY_SPK, 60_000),), ) @@ -483,19 +403,15 @@ def test_keyed_recovery(): ) == "unsatisfied_sig_assert" ) - with pytest.raises(BitLispError) as info: - run(KVAULT, assemble("(2 60000 60000)"), BUDGET) - assert info.value.code == "arg_not_pair" + assert run_error(KVAULT, "(2 60000 60000)") == "arg_not_pair" def test_recovery_cannot_underpay(): - with pytest.raises(BitLispError) as info: - run(VAULT, assemble("(2 60000 59999)"), BUDGET) - assert info.value.code == "user_raise" + assert run_error(VAULT, "(2 60000 59999)") == "user_raise" underfunded = Transaction( version=2, locktime=0, - inputs=(bl_input(TXID, 0, VSPK, 60_000, VROOT), fee_input()), + inputs=(taproot_input(TXID, 0, VSPK, 60_000, VROOT), fee_input(20_000)), outputs=(TxOutput(RECOVERY_SPK, 59_999),), ) assert ( @@ -511,10 +427,10 @@ def follower_conditions(): def consolidation_tx(listed, out_amt, followers=(50_000, 30_000)): listed_text = " ".join(str(a) for a in listed) solution = assemble(f"(4 0x{VSPK.hex()} 60000 {out_amt} ({listed_text}))") - inputs = [bl_input(b"\xd0" * 32, 0, VSPK, 60_000, VROOT)] + inputs = [taproot_input(b"\xd0" * 32, 0, VSPK, 60_000, VROOT)] for i, amount in enumerate(followers): inputs.append( - bl_input( + taproot_input( bytes([0xD1 + i]) * 32, 0, VSPK, @@ -547,9 +463,7 @@ def test_consolidation_rejects_empty_follower_list(): # A leader alone is not a consolidation: every consolidation # merges at least two coins, so conflicting consolidations still # shrink the coin set at this scriptPubKey. - with pytest.raises(BitLispError) as info: - run(VAULT, assemble(f"(4 0x{VSPK.hex()} 60000 60000 ())"), BUDGET) - assert info.value.code == "user_raise" + assert run_error(VAULT, f"(4 0x{VSPK.hex()} 60000 60000 ())") == "user_raise" def test_consolidation_rejects_negative_listed_amount(): @@ -561,9 +475,10 @@ def test_consolidation_rejects_negative_listed_amount(): def test_consolidation_output_cannot_underpay(): - with pytest.raises(BitLispError) as info: - run(VAULT, assemble(f"(4 0x{VSPK.hex()} 60000 139999 (50000 30000))"), BUDGET) - assert info.value.code == "user_raise" + assert ( + run_error(VAULT, f"(4 0x{VSPK.hex()} 60000 139999 (50000 30000))") + == "user_raise" + ) def test_same_target_withdrawals_merge_and_burn(): @@ -575,7 +490,7 @@ def test_same_target_withdrawals_merge_and_burn(): wd_out = TxOutput(bytes.fromhex("0014") + b"\x22" * 20, 39_000) real_target = hashlib.sha256(wd_out.wire).digest() trig_inst, trig_root, trig_spk = instance(TRIG_NODE, trig_values(b"", real_target)) - other = bl_input( + other = taproot_input( b"\xce" * 32, 0, trig_spk, @@ -588,7 +503,7 @@ def test_same_target_withdrawals_merge_and_burn(): version=2, locktime=0, inputs=( - bl_input(b"\xcc" * 32, 1, trig_spk, 40_000, trig_root, sequence=DELAY), + taproot_input(b"\xcc" * 32, 1, trig_spk, 40_000, trig_root, sequence=DELAY), other, ), outputs=(wd_out,), @@ -597,15 +512,13 @@ def test_same_target_withdrawals_merge_and_burn(): def test_unknown_path_raises(): - with pytest.raises(BitLispError) as info: - run(VAULT, assemble("(9)"), BUDGET) - assert info.value.code == "user_raise" + assert run_error(VAULT, "(9)") == "user_raise" def test_vm_vectors_match_source(): - # Every pinned program is byte-identical to a fresh compile and - # curry of the sources, so the corpus cannot drift from them. - cases = load_vector("vm/vault-programs.json") + # Every pinned program and solution is byte-identical to a fresh + # compile, curry, and construction from the sources, so the + # corpus cannot drift from them. malformed_rkey = instance(VAULT_NODE, vault_values(b"\x02" + RKEY_PK))[0] overlong_delay = instance( VAULT_NODE, @@ -635,34 +548,55 @@ def test_vm_vectors_match_source(): b"\x00\x00\x90", ], )[0] - by_program = { - "vault_trigger_no_revault": VAULT, - "vault_trigger_with_revault": VAULT, - "vault_recover_keyless": VAULT, - "vault_recover_keyed": KVAULT, - "vault_follow": VAULT, - "vault_lead_two_followers": VAULT, - "triggered_withdraw": TRIG, - "triggered_recover": TRIG, - "vault_trigger_value_shortfall": VAULT, - "vault_trigger_negative_revault": VAULT, - "vault_trigger_zero_amount": VAULT, - "vault_malformed_recovery_key_unspendable": malformed_rkey, - "vault_overlong_delay_unspendable": overlong_delay, - "vault_nil_recovery_spk_unspendable": nil_recovery_spk, - "vault_padded_delay_unspendable": padded_delay, - "vault_lead_no_followers": VAULT, - "vault_unknown_path": VAULT, - } - assert set(cases) == set(by_program) - for name, node in by_program.items(): - assert cases[name]["program"] == serialize(node).hex(), name + follow = assemble(f"(3 0x{VSPK.hex()})") + nr_sig = sig_my_outpoint( + AUTH_SK, TXID, 0, assemble(f"(1 0x{TARGET.hex()} 60000 0)") + ) + def hostile_trigger(trig_amt, revault_amt, my_amt): + # The failing trigger paths raise on their amount guards + # before the signature assert runs, and the corpus pins + # them carrying the captured no-revault signature, the + # replay an observer could actually attempt. + return assemble( + f"(1 0x{TARGET.hex()} {trig_amt} {revault_amt} {my_amt} 0x{nr_sig.hex()})" + ) -def emitted_hex(program, solution): - if isinstance(solution, str): - solution = assemble(solution) - return serialize(run(program, solution, BUDGET)[1]).hex() + expected = { + "vault_trigger_no_revault": ( + VAULT, + trigger_solution(AUTH_SK, TXID, 0, TARGET, 60_000, 0, 60_000), + ), + "vault_trigger_with_revault": ( + VAULT, + trigger_solution(AUTH_SK, TXID, 0, TARGET, 40_000, 20_000, 60_000), + ), + "vault_recover_keyless": (VAULT, assemble("(2 60000 60000)")), + "vault_recover_keyed": ( + KVAULT, + keyed_recovery_solution(TXID, 60_000, 60_000), + ), + "vault_follow": (VAULT, follow), + "vault_lead_two_followers": ( + VAULT, + assemble(f"(4 0x{VSPK.hex()} 60000 140000 (50000 30000))"), + ), + "triggered_withdraw": (TRIG, assemble("(1)")), + "triggered_recover": (TRIG, assemble("(2 40000 40000)")), + "vault_trigger_value_shortfall": (VAULT, hostile_trigger(30_000, 0, 60_000)), + "vault_trigger_negative_revault": (VAULT, hostile_trigger(60_001, -1, 60_000)), + "vault_trigger_zero_amount": (VAULT, hostile_trigger(0, 60_000, 60_000)), + "vault_malformed_recovery_key_unspendable": (malformed_rkey, follow), + "vault_overlong_delay_unspendable": (overlong_delay, follow), + "vault_nil_recovery_spk_unspendable": (nil_recovery_spk, follow), + "vault_padded_delay_unspendable": (padded_delay, follow), + "vault_lead_no_followers": ( + VAULT, + assemble(f"(4 0x{VSPK.hex()} 60000 60000 ())"), + ), + "vault_unknown_path": (VAULT, assemble("(9)")), + } + assert_vm_vectors_match("vm/vault-programs.json", expected) def lead_hex(listed, out_amt): @@ -722,12 +656,10 @@ def test_validation_vectors_match_source(): ).hex(), serialize(assemble(f"((0x42 98 () 0x{VSPK.hex()}))")).hex(), } - files = [ - load_vector("validation/vault-core.json"), - load_vector("validation/vault-consolidation.json"), - ] - observed = {entry["conditions"] for _, entry in condition_inputs(files)} - assert observed == expected + assert_conditions_closure( + ("validation/vault-core.json", "validation/vault-consolidation.json"), + expected, + ) def test_validation_vector_identities_derive_their_scripts(): diff --git a/vectors/vm/compiled-programs.json b/vectors/vm/compiled-programs.json index f3f06c9..15be927 100644 --- a/vectors/vm/compiled-programs.json +++ b/vectors/vm/compiled-programs.json @@ -153,6 +153,33 @@ "expect": { "error": "user_raise" } + }, + { + "name": "let_names_a_value_once", + "program": "ff02ffff01ff02ffff01ff10ff05ff05ff0b80ffff04ff02ffff04ffff02ff02ffff04ff02ffff04ff05ff80808080ff03808080ffff04ffff01ff12ffff0102ff0580ff018080", + "env": "ff0580", + "expect": { + "result": "19", + "cost": 3231 + } + }, + { + "name": "nested_let_shadows_outer", + "program": "ff02ffff01ff02ffff0102ffff04ffff10ff02ffff010180ff018080ffff04ffff12ff02ff0280ff018080", + "env": "ff0680", + "expect": { + "result": "25", + "cost": 2380 + } + }, + { + "name": "list_star_conses_onto_tail", + "program": "ff04ff02ffff04ff05ff0b8080", + "env": "ff01ff02ffff05ff068080", + "expect": { + "result": "ff01ff02ff05ff0680", + "cost": 258 + } } ] }