Skip to content

Unit 8: the reference witness layer, ASSERT_MY_ANNEX, and bitlisp-commit - #68

Open
EvanWinget wants to merge 17 commits into
mainfrom
unit-8-witness-layer
Open

Unit 8: the reference witness layer, ASSERT_MY_ANNEX, and bitlisp-commit#68
EvanWinget wants to merge 17 commits into
mainfrom
unit-8-witness-layer

Conversation

@EvanWinget

@EvanWinget EvanWinget commented Sep 6, 2026

Copy link
Copy Markdown
Owner

The second half of unit 8: the reference witness layer over the commitment scheme PR 67 specified, ASSERT_MY_ANNEX, the element bound, every stage 1 to 3 failure vector, the 0x39 gap vector flipping, and the commitment-hash utility.

What changed

  • ASSERT_MY_ANNEX (0x39) parses as a self assert over one 32-byte operand. The transaction view's BitLisp input gains an optional annex_hash. The assert compares against it, and the admission rule (an annex with no assert is unasserted_annex) is one function run at stage 5 by the spend entry and first in validate_transaction.
  • python/bitlisp/commitment.py: the tree hash (moved from the compiler, which now imports it), BIP340 tagged hash, TapLeaf hash, TapBranch fold, sha_annex, and a parsed ControlBlock with base consensus's check against the spent scriptPubKey.
  • python/bitlisp/spend.py: evaluate_spend(tx_input, witness, max_cost) runs SPEC.md section 4's stages 1 to 5 and returns the cost and the input as the view carries it. Base consensus's checks run first as a precondition and raise BaseConsensusError, a class outside the error taxonomy, because the spec gives such a spend no outcome.
  • A fourth vector suite, vectors/spend/: witness.json (section 3.1's rules in order and the stage order), element-bound.json (10,000 and 10,001 bytes for the solution, the program, and the annex), programs.json (the atom 1, nil, surplus tolerance, the annex both ways), identity.json (paths of 0, 1, 2, and 128, both sort orders, both parity bits).
  • bitlisp-commit, the eighth console script: leaf script, tapleaf, merkle root, internal key, control block, scriptPubKey from a program.
  • Mutation pass over the seven touched modules, 1,027 mutants: every survivor in the two new modules dies under pytest, one gap became a vector (the annex digest's compact-size boundary at 253 bytes), one dead function came out, and the new survivor class (checks base consensus makes before a spend reaches BitLisp) is recorded in docs/mutation-triage.md.
  • Two spec corrections the vectors and tests forced, each its own commit: SPEC.md 3.3 said the nil program's empty list is rejected, contradicting CONDITIONS.md, VALIDATION.md rule 5, and three corpus files that pin an empty list valid. The 2.4 digest table named BIP340/challenge with first byte 0x07, and BIP340's tag is BIP0340/challenge, first byte 0x7b.

Three implementation choices, steelmanned both ways in the session and ratified by Evan, are recorded in docs/commitment-record.md section 5: base consensus's checks as an out-of-taxonomy precondition, the annex rule run on both paths, and the runner and REPL witness form deferred to unit 9's first PR, where the vault is the first consumer.

Spec sections

SPEC.md 2.2 and 2.3 (the leaf hash and the identity read from the control block), 3.1 (the six witness rules), 3.2 (explicit budgets in every vector until COSTS.md section 9 exists), 3.3 (the two named programs), 3.4 (the annex), 4 (the stage table). CONDITIONS.md's ASSERT_MY_ANNEX entry. VALIDATION.md's transaction view (annexHash). Condition-record decision 30, commitment-record decisions 3, 4, 5, and 12.

Reading order

  1. 66fd49d conditions: ASSERT_MY_ANNEX and the admission rule (implementation and invariants)
  2. 5253749 vectors: 0x39 flips from gap to ASSERT_MY_ANNEX (the wrong vector's spec citation is in the message)
  3. f454598 spec: the nil program is spendable by anyone
  4. 4ca2488 spend: the witness layer (commitment.py, spend.py, the two new error codes, taproot_output_point)
  5. 1422ede spec: the challenge tag row
  6. 843b9a9 vectors: the spend suite (runner and four files)
  7. 43bcbd2 tests: the witness layer against its oracles
  8. a177b76 tools: bitlisp-commit
  9. 9ba6c27 docs: the records, glossary, and REPL doc
  10. 6bcbe76 secp256k1: the dead x-only tweak helper removed (found by the mutation pass)
  11. 629b04b vectors: the annex digest's compact-size boundary (found by the mutation pass)
  12. 7b118c5 docs: the mutation pass of 2026-09-06 in the triage record

Fold-ins from a max-effort review of the PR head (no consensus defect found, 10 findings, all applied):

  1. d907f8c spec: the self assert family counts six, the view's annex hash on a plain input
  2. bd89255 spend: one tagged hash (four spellings became one), one compact size, the tree hash as the operator, the annex rule moved to the per-input stage, ControlBlock.build and serialize
  3. 586fdcc tools: bitlisp-commit refuses more than 128 siblings, builds the block through the library
  4. ff05ed2 tests: the digest-domain completeness check scans every tagged_hash call site, a control block round trip, the lone-assert property back at full density
  5. 2507c52 docs: the parity bit in the bitlisp-commit example, the record's two added rationales

Verify

.venv/bin/pip install -e ".[dev,oracles]"      # the new console script
.venv/bin/python tools/run_vectors.py            # 42 files, 1,201 cases
.venv/bin/pytest python/tests                    # 913, includes test_spend.py's BIP341 differential
PATH=.venv/bin:$PATH ci/lint/lint.sh
.venv/bin/bitlisp-commit "1"                     # the anyone-can-spend leaf under NUMS
.venv/bin/python tools/mutate.py --tests --jobs 8 --module commitment --module spend

The spend vectors were generated from predicted outcomes: the generator asserted the implementation against the spec's prediction for each case before writing it, and the identity cases were cross-checked against the vendored Bitcoin Core tagged hash. To re-derive one by hand, bitlisp-commit "(q)" prints the leaf script, control block, and scriptPubKey that the single_leaf case of spend/identity.json carries.

Not in this PR

  • The runner and REPL witness form (unit 9's first PR).
  • The execution-plan bullets for unit 8 and the commitment-hash utility, updated direct to main after merge.
  • COSTS.md section 9: every spend vector states its budget.

The seventh self assert, CONDITIONS.md's ASSERT_MY_ANNEX entry: one
32-byte operand at the generic cost, parsed through the fixed-width
rule every 32-byte identity operand shares. The transaction view's
BitLisp input gains an optional annex_hash, the BIP341 sha_annex
digest of the annex its witness carries, None without one. The
assert compares its operand against that field byte-exact, so an
input without an annex fails it (unsatisfied_annex_assert).

The admission rule of SPEC.md section 3.4: an input carrying an
annex hash whose condition list holds no ASSERT_MY_ANNEX is
unasserted_annex. It reads the input alone. validate_transaction
runs it over every assembled input before any other rule, because a
view built without the spend pipeline (every validation vector, the
runner's carried inputs) must satisfy the same invariant, and the
witness layer will run the same function at its own stage.

The runner's tx shape and the compiler's vocabulary mirror gain the
field and the name. Invariants: the self assert suite draws the
input's own annex hash and annex operands from colliding pools, so
environment independence and field equality now cover the assert,
and a new property pins that unasserted_annex fires exactly when an
annex is present and unasserted. The duplicate suite adds the
assert to its idempotent pool.
gap_0x39_invalid pinned bad_condition_opcode while the vocabulary
table of CONDITIONS.md section 2 already assigned 0x39 to
ASSERT_MY_ANNEX (spec commit d50da1e, condition-record decision
30). The vector was wrong against the spec from that commit and
stayed so on purpose until the implementation landed. It is
replaced by the assert's parse case and its rejections: 31 and 33
byte operands, an empty operand, a pair operand, arity zero and
two.

costs.json gains the assert's 200 line, its exact-budget pair, and
the annex condition in the all-opcodes sum, whose total rises by
200 to 14,405,400 (COSTS.md section 10, one generic entry more).

validation/self-asserts.json pins the assert and the admission
rule of SPEC.md section 3.4 in both directions: satisfied, wrong
hash, assert without an annex, annex without the assert (with an
empty list, with a satisfied assert, and with a failing assert the
admission rule precedes), an annex asserted then a later assert
failing on its own error, duplicate and contradictory annex
asserts, and an annex hash on a plain input, which no rule reads.
The annex every case hashes is 0x50 then the ASCII bytes "annex".
Section 3.3 said the nil program's empty condition list is rejected
as bad_condition_list and the nil leaf has no valid spend. It
contradicted CONDITIONS.md section 1, under which the empty list is
a proper list and parses to no conditions, VALIDATION.md rule 5,
which says the empty list charges nothing and satisfies any budget,
and the corpus, where conditions/costs.json pins the empty list as
valid and three validation files carry a valid input whose list is
empty. Chialisp agrees: a coin spend producing no conditions is
valid. The section now states what the rules give: the nil leaf is
spendable by anyone, its spend contributing no condition. The two
named programs differ in who chooses the conditions, the spender
under the atom 1 and nobody under nil.

Found while writing the witness layer's vector for the nil program.
Two consensus modules. commitment.py is the hashing of section 2:
the tree hash over a node (moved here from the compiler, which now
imports it, since the leaf script is consensus data), BIP340's
tagged hash, the TapLeaf hash under a leaf version, the TapBranch
fold of a control block's path, the annex digest, and a control
block parsed into leaf version, parity, internal key, and path.
spend.py is sections 3 and 4: evaluate_spend takes the input as the
transaction carries it before evaluation, the witness elements in
serialization order, and the budget, and returns the accrued cost
with the input carrying its condition list, execution identity, and
annex hash.

The stages run in the spec's order. The annex is set aside by the
BIP341 rule (two or more elements, the last beginning 0x50), then
base consensus's own checks run as a precondition: two or more
elements, a control block of 33 plus 32m bytes with m at most 128,
BitLisp's leaf version, and the internal key tweaked by the revealed
leaf's root giving the spent scriptPubKey with the parity bit
matching. Those checks precede stage 1 in the spec and a spend
failing them never reaches it, so they raise BaseConsensusError, a
class outside the error taxonomy, never a code a vector could pin
as BitLisp behavior. secp256k1 gains taproot_output_point, the full
point, so the parity bit can be read. Stage 1 is widths: four
elements, a 32-byte leaf script, the solution, program, and annex
each at most MAX_WITNESS_ELEMENT_SIZE = 10,000 bytes, bad_witness.
Stage 2 decodes the program and compares its tree hash to the leaf
script, leaf_mismatch. Stage 3 decodes the solution. Stage 4 is
run, stage 5 parse_conditions on the same budget and then the annex
admission rule, the same function validate_transaction runs.

The two new codes join CODES beside bad_encoding. Every element
decodes under the strict deserializer, so an element with trailing
bytes is bad_encoding at its stage, the surplus tolerance being
inside the solution node, not around it.
The digest-domain table of section 2.4 named the tag
BIP340/challenge with first byte 0x07. BIP340 spells its challenge
tag BIP0340/challenge, the string secp_verify and the signature
asserts hash under, whose digest begins 0x7b. Neither byte is 0x01
or 0x02, so the domain argument stood either way, but the table
pins the tags a validator hashes under and must name them as
hashed. Found by the test that computes every row of the table
from the tag strings the implementation uses.
A fourth suite, one input's witness through the per-input stages of
SPEC.md section 4. A case is the spent scriptPubKey, the witness
elements in serialization order, and an explicit budget (section
3.2 is PROVISIONAL until COSTS.md section 9 fixes the function, so
each case states its own). Success pins the parsed conditions, the
three identity fields read from the control block, the annex hash
when there is one, and the cost. A witness base consensus would
refuse, or one under another leaf version, is a malformed vector,
because the spec gives it no outcome.

Four files. witness.json is section 3.1's rules in order and the
stage order: the element count with and without an annex, the
0x50-tagged first element that is a solution and not an annex, the
leaf script at 0, 31, and 33 bytes, each program and solution
decoding failure, leaf_mismatch and what it precedes, the VM error
and the parse error each preceding unasserted_annex, and the budget
at one below and exactly the total. element-bound.json is rule 4 at
and one above MAX_WITNESS_ELEMENT_SIZE for the solution, the
program, and the annex, and the bound preceding the stage 2
failures. programs.json is section 3.3's two named programs, the
atom 1 and nil, surplus solution data ignored, and section 3.4's
annex in both directions at this stage: admitted under its assert,
unasserted_annex without it, and the two stage 6 failures (another
hash, no annex) passing the input stages as the spec places them.
identity.json is sections 2.2 and 2.3: paths of 0, 1, 2, and 128
elements with siblings sorting on both sides of the leaf, two
internal keys, both parity bits, cross-checked at generation
against the vendored Bitcoin Core tagged hash.

Each case states the outcome the spec predicts and the generator
checked the implementation against that prediction, so the files
pin predicted behavior, not observed.
The commitment scheme's hashing has real oracles and this file uses
them. The official BIP341 wallet vectors give every leaf's control
block, leaf hash, merkle root, and scriptPubKey under leaf versions
0xc0 and 0xfa: ControlBlock reads all twelve to the published
identity, passes the base consensus check against the published
output, and rejects a flipped output byte, a flipped parity bit,
and a lengthened leaf script. The vendored Bitcoin Core framework
is the oracle for the tagged hash over every tag the package uses,
the leaf hash under any leaf version, and the annex digest's
serialization, including the three-byte compact size a bound-sized
annex needs. The tree hash is checked against the VM's sha256tree
operator, which the differential harness pins to the consensus
wheel.

The digest-domain table of SPEC.md section 2.4 is recomputed from
the tag strings: every first byte, none 0x01 or 0x02, and the table
is exactly the set of tags the package hashes under, so a new tag
fails here until the spec's table gains its row. Found the table's
BIP340/challenge row wrong, fixed in the spec commit before this.

The spend entry's invariants: under the anyone-can-spend program an
annex passes the input stages exactly when the solution asserts its
hash, the element bound is exact at MAX_WITNESS_ELEMENT_SIZE, the
identity is the leaf hash and the path fold whatever the path, the
annex split follows BIP341 on its edge cases, and a key-path spend
or a foreign leaf version raises BaseConsensusError.
The commitment-hash utility queued 2026-08-14 and moved to this PR
when the scheme fixed its output. From a program, as text or as
serialized hex, it prints the leaf script (the tree hash), the
tapleaf hash under BitLisp's leaf version, the merkle root, the
internal key, the control block a spend of the leaf carries, and
the taproot scriptPubKey. --sibling names the path elements leaf
upward for a leaf that shares its tree, and --internal-key replaces
the default, the BIP341 nothing-up-my-sleeve point, so the default
output has no key path. The control block is the piece a spender
needs and the one no other tool produces.

Tests pin its output against the spend suite's identity vectors,
which were cross-checked against the Bitcoin Core tagged hash, and
its exit 2 paths: a program that does not parse or decode, a
sibling that is not hex, a key of the wrong width, a key that does
not lift. The eighth console script, listed with the others.
The commitment record gains a section for the reference witness
layer: the three choices made in landing it (base consensus's
checks run as a precondition outside the taxonomy, the annex rule
run once per stage on two paths, the runner's witness form deferred
to unit 9), and the two spec corrections the vectors and tests
forced (the nil program, the challenge tag). Decision 3's owed
vector is the test that recomputes the digest table, described
where the vector was promised. The carried list loses the two items
this PR delivered. The condition record's C25 row and decision 30
name the files their owed vectors landed in, the VM record's entry
7 the same for the disjointness pin. The glossary gains the control
block, a term the spec used without a row. The REPL doc lists
bitlisp-commit with the other one-shot commands and shows its
output.
taproot_output_key now reads the x coordinate off taproot_output_point,
so _apply_tweak had no caller in the consensus path, which the mutation
pass showed as a survivor. Removed. Its three boundary tests, the
scalar at the group order, the scalar one below it, and the sum at
infinity, move onto _tweaked_point and compare points.
The mutation pass moved the compact-size threshold by one and the
corpus did not notice: no annex sat on either side of 253 bytes,
where BIP341's sha_annex prefix turns three bytes wide. Two cases in
spend/programs.json, an annex of 252 bytes under the one-byte prefix
and one of 253 under fd plus two little-endian bytes, each asserted
by its program (SPEC.md section 3.4).
The seven touched modules, 1,027 mutants. Every survivor in the two
new modules falls to the pytest suite. One gap became a vector, one
dead function was removed, and one survivor class is new: checks
base consensus makes before a spend reaches BitLisp, which the spend
suite treats as malformed by design and no vector can kill.
CONDITIONS.md's self assert heading and count still read five
conditions at 0x30 to 0x33 and 0x38 while the entry for 0x39, the
cost table's "each self assert" row, and the implementation all
file ASSERT_MY_ANNEX under the family. Six, with the annex hash
named among the facts the family reads.

VALIDATION.md's transaction view said an input without a condition
list may carry the triple or not. It may carry annexHash or not on
the same terms, and no rule reads either: the sentence now says so,
the vector annex_on_plain_input_unread having pinned a shape the
spec did not name.
Four spellings of BIP340's tagged hash became one, secp256k1's
tagged_hash, which the challenge digest, the tweak scalar, the leaf
and branch hashes, and the signature-assert digests all call. The
digest-domain test now scans that one function's call sites, so a
new tag cannot enter the package unseen. tx.py's compact-size prefix
was a copy of commitment.py's and imports it instead. The tree hash
is the sha256tree operator run with its charges discarded, not a
second copy of its walk. conditions.py derives a taproot
scriptPubKey through commitment.taproot_script_pubkey, which gives
that function its consensus caller.

The annex admission rule moves to spend.py, the per-input stage it
belongs to, and validate_transaction imports it, so the dependency
runs from the later stage to the earlier one. ControlBlock gains
build, which derives the parity bit from the output key, and
serialize, the inverse of parse, so the forward construction exists
in the library once. The tree hash on the leaf_mismatch path is
computed once, and TxInput's four optional 32-byte fields share one
width check.
The command refused nothing above 128 siblings and printed a
control block and scriptPubKey for a leaf base consensus can never
spend. It now exits 2 past MERKLE_PATH_MAX_DEPTH. The control block
comes from ControlBlock.build and the scriptPubKey from
taproot_script_pubkey rather than hand assembly, and the doc says
the first byte's low bit is the output key's parity, so d0 and d1
are both expected. The tools package imports the tree hash from the
consensus module directly.
The digest-domain completeness test scanned a fixed tuple of four
tags, so a new tagged_hash call would have passed it. It now
collects every literal tag at a tagged_hash call site plus the
signature bindings' tags, and checks the function is defined once.
A round-trip property covers ControlBlock.build, serialize, parse,
and check together. The lone self assert equality property draws an
annex only under an annex assert again, so the five older families
reach the equality check on every example rather than a third of
them, the admission rule having its own property.
The REPL doc's bitlisp-commit example showed a control block whose
first byte the reader would take as constant. It now shows the real
output for the atom 1 and says the low bit is the output key's
parity. The commitment record states why the digest-domain table is
pinned by a test rather than the vector decision 3 first promised,
and where the annex rule lives.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant