From 386d0d7759e08c948543b1f7928f247034f6f6c7 Mon Sep 17 00:00:00 2001 From: Breno Brito Date: Fri, 11 Sep 2026 01:00:13 -0300 Subject: [PATCH 1/4] feat(commitments): add ternary mixed-hash integer path --- examples/ternary_hash_path_benchmark.rs | 32 ++ knowledge/catalog.json | 71 ++++- knowledge/comparisons/commitments.md | 9 +- knowledge/index.md | 2 +- knowledge/negative-results/index.md | 10 + knowledge/open-problems.md | 10 + knowledge/primitives/index.md | 1 + .../primitives/ternary-hash-path-integer.md | 72 +++++ research/ternary-hash-path/README.md | 56 ++++ src/commitments/README.md | 45 ++- src/commitments/mod.rs | 6 + src/commitments/ternary_hash_path.rs | 279 ++++++++++++++++++ tests/primitive_metrics.rs | 53 ++++ 13 files changed, 640 insertions(+), 6 deletions(-) create mode 100644 examples/ternary_hash_path_benchmark.rs create mode 100644 knowledge/primitives/ternary-hash-path-integer.md create mode 100644 research/ternary-hash-path/README.md create mode 100644 src/commitments/ternary_hash_path.rs diff --git a/examples/ternary_hash_path_benchmark.rs b/examples/ternary_hash_path_benchmark.rs new file mode 100644 index 00000000..56ad3cb7 --- /dev/null +++ b/examples/ternary_hash_path_benchmark.rs @@ -0,0 +1,32 @@ +use bitcoin::consensus::encode::serialize; +use bitcoin::Witness; +use bitcoin_lab::commitments::{ + ternary_hash_path_integer_commitment, ternary_hash_path_integer_witness, + verify_ternary_hash_path_to_integer, +}; +use bitcoin_lab::support::execution::execute_script_with_inputs_strict; +use bitcoin_lab::support::script::ScriptCompilation; + +fn main() { + let preimage = [0x42; 32]; + let value = 0x1234_5678; + let commitment = ternary_hash_path_integer_commitment(&preimage, value, 31); + let witness = ternary_hash_path_integer_witness(&preimage, value, 31); + let verifier = verify_ternary_hash_path_to_integer(31, commitment); + let execution = execute_script_with_inputs_strict(verifier.clone(), witness.clone()); + assert!(execution.success, "benchmark fixture failed: {execution}"); + let script_bytes = verifier.compile_with_policy().len(); + + println!("primitive=ternary_hash_path_integer"); + println!("bit_width=31"); + println!("trit_count=20"); + println!("script_bytes={script_bytes}"); + println!( + "witness_bytes={}", + serialize(&Witness::from_slice(&witness)).len() + ); + println!("witness_items={}", witness.len()); + println!("hint_items=0"); + println!("executed_opcodes={}", execution.stats.opcode_count); + println!("commitment_bytes={}", commitment.len()); +} diff --git a/knowledge/catalog.json b/knowledge/catalog.json index 496c3eb3..5198f5cf 100644 --- a/knowledge/catalog.json +++ b/knowledge/catalog.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "as_of": "2026-09-01", + "as_of": "2026-09-11", "cost_model": "knowledge/cost-model.md", "records": [ { @@ -1499,6 +1499,75 @@ "OP-004" ] }, + { + "id": "commitment/ternary-hash-path-integer", + "name": "Ternary mixed-hash integer path", + "class": "commitment/integer", + "summary": "Authenticates canonical base-3 trits with fixed-length SHA-256/RIPEMD-160 codewords and reconstructs a 1–31-bit integer.", + "status": "experimental", + "evidence": "locally-reproduced", + "execution": "unclassified", + "as_of": "2026-09-11", + "knowledge_page": "knowledge/primitives/ternary-hash-path-integer.md", + "implementation": "src/commitments/ternary_hash_path.rs", + "documentation": "src/commitments/README.md", + "tests": [ + "commitments::ternary_hash_path::tests::verifies_all_ternary_codewords", + "commitments::ternary_hash_path::tests::verifies_integer_boundaries_and_values", + "commitments::ternary_hash_path::tests::rejects_wrong_openings_and_noncanonical_trits", + "commitments::ternary_hash_path::tests::rejects_out_of_range_generic_trits", + "primitive_metrics::ternary_hash_path_metrics_are_current", + "examples/ternary_hash_path_benchmark.rs" + ], + "references": [ + "bip-342", + "bitcoin-scriptexec-locked", + "bitcoin-script-locked", + "fips-180-4" + ], + "techniques": [ + "mixed-hash-path" + ], + "security": "The final RIPEMD-160 digest limits generic collision resistance to 80 bits; hiding requires min-entropy in the unrevealed preimage/trits, and binding assumes the non-standard three-codeword mixed-hash schedule.", + "stack_contract": "... tritN-1 ... trit0 preimage -> ... value", + "configurations": [ + { + "id": "integer-31", + "label": "31-bit ternary integer path", + "parameters": { + "bit_width": 31, + "trit_count": 20, + "preimage_bytes": 32 + }, + "includes": "fragment-only: verifier and base-3 integer reconstruction; witness includes serialized trits and preimage", + "script_bytes": 924, + "witness_bytes": 63, + "witness_bytes_max": 63, + "max_stack_items": 24, + "executed_opcodes": null, + "validation_weight": null, + "setup_script_bytes": 0, + "per_use_script_bytes": 924, + "metric_keys": [ + "ternary_hash_path_integer_31", + "ternary_hash_path_integer_witness_31", + "ternary_hash_path_integer_stack_31" + ] + } + ], + "limitations": [ + "Non-standard mixed-hash construction without dedicated cryptanalysis", + "Integer reconstruction limited to 31 bits and 20 trits at that width", + "Dominated by the measured four-way path for ordinary 31-bit integer bytes and stack usage", + "All trits coexist at script entry; surrounding protocol state must be charged against the 1,000-item stack limit", + "Bitcoin Core consensus and policy validation not performed" + ], + "open_problems": [ + "OP-002", + "OP-003", + "OP-020" + ] + }, { "id": "commitment/four-way-hash-path-integer", "name": "Four-way mixed-hash integer path", diff --git a/knowledge/comparisons/commitments.md b/knowledge/comparisons/commitments.md index 34e30a12..d585db65 100644 --- a/knowledge/comparisons/commitments.md +++ b/knowledge/comparisons/commitments.md @@ -4,7 +4,8 @@ | --- | --- | ---: | ---: | ---: | --- | | Preimage length | `len(preimage)-offset` | 44 | 18–524 | 3 | Range coupled to item size | | Mixed hash path | 31 authenticated bits | 520 | 78 | 34 | Mixed-hash assumption; wider opcode cost | -| Four-way mixed hash path | 16 authenticated base-4 digits / 31 bits | 453 | 61 | 19 | Tapscript `MINIMALIF` required; non-standard mixed-hash code | +| Four-way mixed hash path | 16 authenticated base-4 digits / 31 bits | 438 | 61 | 19 | Tapscript `MINIMALIF` required; non-standard mixed-hash code | +| Ternary mixed hash path | 20 authenticated base-3 trits / 31 bits | 924 | 63 | 24 | Native ternary state encoding; larger than four-way path | | Lamport 2-bit | Select one of four preimages | 96 | 11 | small | Strictly one-time | The schemes have different semantics. Preimage length is compact but encodes @@ -14,3 +15,9 @@ the four-way path saves 67 script bytes, 17 witness bytes, and 15 peak stack items relative to the binary path. This comparison does not erase its stronger tapscript-only execution assumption or its non-standard mixed-hash security assumption. + +The ternary path is not a byte-efficiency improvement for this integer target: +it uses one more witness item than the four-way path and is 486 bytes larger. +It is retained as a different representation point for protocols whose state +is naturally three-valued. Its local implementation performs explicit trit +canonicality checks instead of relying on tapscript `MINIMALIF`. diff --git a/knowledge/index.md b/knowledge/index.md index a7aa3cd2..efe1f878 100644 --- a/knowledge/index.md +++ b/knowledge/index.md @@ -6,7 +6,7 @@ reproducible constructions. The local Rust library is one source of evidence; it is not the boundary of the atlas. The catalog is explicitly time-scoped. Its current review date is -**2026-09-04**. A record's `as_of` field says when its claims were last checked. +**2026-09-11**. A record's `as_of` field says when its claims were last checked. Missing records are unknown coverage, not proof of nonexistence. ## How to answer a research question diff --git a/knowledge/negative-results/index.md b/knowledge/negative-results/index.md index d4831764..4ccbbd2d 100644 --- a/knowledge/negative-results/index.md +++ b/knowledge/negative-results/index.md @@ -1226,3 +1226,13 @@ selector and then unwrap-panics. A dedicated test reproduces that panic; it must not be counted as a clean local rejection or Core validation. Negative and larger positive indices are tested separately. This executor limitation and missing complete-protocol validation remain under OP-009. +## NR-043: Ternary mixed-hash paths lose to four-way integer paths + +The ternary path was implemented as a native three-valued alternative using +`0 -> SS`, `1 -> SR`, and `2 -> RS`, with explicit canonical trit checks. At +31 bits and a 32-byte preimage it measures 924 script bytes, 63 serialized +witness bytes, and a 24-item peak, versus 438/61/19 for the four-way path. +It is therefore dominated for the measured ordinary integer objective and is +not retained as a byte-efficiency improvement. The result does not rule out a +ternary path when protocol state is naturally three-valued or when a different +consumer amortizes its dispatcher. diff --git a/knowledge/open-problems.md b/knowledge/open-problems.md index 4e2bc24c..41c91624 100644 --- a/knowledge/open-problems.md +++ b/knowledge/open-problems.md @@ -3,6 +3,16 @@ Each problem has a falsifiable completion criterion. Update comparisons and negative results when closing one. +## OP-020 — Ternary commitment composition frontier + +Determine whether the ternary mixed-hash path becomes useful when a protocol +consumes native three-valued state rather than reconstructing an ordinary +integer. **Complete when:** at least one ternary protocol composition is +implemented with its terminal predicates and surrounding state, compared on a +like-for-like boundary against binary and four-way alternatives, and the +three-codeword mixed-hash binding assumption receives an independent analysis +or a pinned Core differential fixture. + ## OP-019 — PRINCEv2 M-hat circuit frontier Find a smaller repeated M-hat circuit for generation-time-key encryption. diff --git a/knowledge/primitives/index.md b/knowledge/primitives/index.md index a4166075..414c336b 100644 --- a/knowledge/primitives/index.md +++ b/knowledge/primitives/index.md @@ -23,6 +23,7 @@ the source. Read a page together with its comparison page and evidence record. - [Mixed-hash path commitment](hash-path-integer.md) - [Four-way mixed-hash integer path](four-way-hash-path-integer.md) +- [Ternary mixed-hash integer path](ternary-hash-path-integer.md) - [Preimage-length integer](preimage-length.md) - [Binohash transaction digest](binohash.md) diff --git a/knowledge/primitives/ternary-hash-path-integer.md b/knowledge/primitives/ternary-hash-path-integer.md new file mode 100644 index 00000000..066995bb --- /dev/null +++ b/knowledge/primitives/ternary-hash-path-integer.md @@ -0,0 +1,72 @@ +# Ternary mixed-hash integer path + +Authenticates fixed-width base-3 digits with three fixed-length SHA-256/ +RIPEMD-160 codewords and reconstructs a 1–31-bit non-negative Script integer. + +## Question and hypothesis + +Can a canonical three-valued hash path provide a useful middle point for +protocol state that is naturally ternary, while remaining within Bitcoin +Script's per-item and combined-stack limits? The hypothesis was that explicit +trit validation would make the representation composable even if its ordinary +31-bit integer cost lost to the existing binary and four-way paths. + +## Construction + +Let `S` be SHA-256 and `R` be RIPEMD-160. Each trit selects exactly two hashes: + +```text +0 -> SS 1 -> SR 2 -> RS +``` + +`RR` is deliberately unused. The path processes least-significant trits +first, finishes with `R`, and compares the resulting 20-byte commitment. The +integer adapter uses the smallest fixed number of base-3 digits covering the +requested width; 31 bits require 20 trits. Witness order is +`tritN-1 ... trit0 preimage`, with zero encoded as the empty vector and the +other trits as exactly `[01]` or `[02]`. + +The Script fragment explicitly rejects padded, negative-zero, and out-of-range +trit encodings. It then reconstructs the committed value as +`3*acc + trit` while draining the saved trits from the altstack. + +## Evidence and representative cost + +Evidence is `locally-reproduced`: all three codewords, integer boundaries, +wrong openings, non-canonical encodings, and out-of-range trits pass focused +tests. The local tests use the strict tapscript-context executor; no Bitcoin +Core consensus or relay-policy comparison has been performed, so deployment is +`unclassified`. + +For a 32-byte preimage and a 31-bit value: + +| Fragment | Script bytes | Serialized witness | Witness items | Peak items | +| --- | ---: | ---: | ---: | ---: | +| `verify_ternary_hash_path_to_integer` | 924 | 63 | 21 | 24 | + +The benchmark reports zero auxiliary hint items. These are fragment-only +measurements: the verifier and integer reconstruction are included, while +input pushes, terminal predicates, and transaction framing are excluded. +The strict local tapscript benchmark's legacy `opcode_count` reports `0`, so +executed-opcode count remains unavailable rather than being inferred from the +static script. + +The construction is larger than the measured four-way path (438 bytes, 61 +witness bytes, 19 peak items) for ordinary 31-bit integers. Its value is the +native three-way selector, not a claim of Pareto improvement. + +## Security and deployment + +The final RIPEMD-160 digest gives the usual generic 80-bit collision bound and +the mixed schedule is not independently cryptanalysed. Hiding still requires +min-entropy in the unrevealed preimage/trit pair. Exact byte canonicality is +enforced by the fragment, but protocol callers must still bind the path length, +bit width, commitment, participant/round context, and terminal predicate. + +All trits are present at script entry and there are no hint items. The 20-trit +representative stays below the 1,000-item combined stack limit in the strict +local test, but composition with surrounding protocol state must be measured. + +See the [implementation README](../../src/commitments/README.md), the +[commitment comparison](../comparisons/commitments.md), and catalog record +`commitment/ternary-hash-path-integer`. diff --git a/research/ternary-hash-path/README.md b/research/ternary-hash-path/README.md new file mode 100644 index 00000000..94687501 --- /dev/null +++ b/research/ternary-hash-path/README.md @@ -0,0 +1,56 @@ +# Ternary mixed-hash integer path + +- **Question:** Can a canonical three-valued mixed-hash path authenticate a + small integer for ternary protocol state? +- **Hypothesis:** Explicit canonical trit checks make the representation safe + to compose, even if its 31-bit integer cost is dominated by the four-way + path. +- **Catalog record:** `commitment/ternary-hash-path-integer` +- **Comparison objective:** Compare a 31-bit, 32-byte-preimage ternary path + with the existing binary and four-way paths under the fragment-only boundary. +- **Repository commit:** record the merge commit in the PR that adds this + experiment. +- **External source revisions:** `bitcoin-script-locked`, `bitcoin-scriptexec-locked`, + BIP 342, and FIPS 180-4 as catalog references. +- **Interpreter and execution class:** centralized policy compiler; focused + correctness tests use the strict local tapscript-context executor; deployment + is `unclassified`. +- **Deterministic vector:** 32 bytes of `0x42`, value `0x12345678`, 31 bits. + +## Reproduction + +```sh +cargo test --locked ternary_hash_path --lib +cargo test --locked --test primitive_metrics ternary_hash_path_metrics_are_current +cargo run --locked --example ternary_hash_path_benchmark +``` + +## Measurement boundary + +The verifier and base-3 reconstruction are included. Input pushes, terminal +predicates, transaction framing, and unrelated protocol state are excluded. +The witness includes all 20 canonical trit items, the 32-byte preimage, and +Bitcoin witness serialization framing. There are zero auxiliary hint items. + +## Results + +The 31-bit representative is 924 policy-produced script bytes, 63 serialized +witness bytes, 21 witness items, and a 24-item combined local peak. It is +larger than the four-way path for this integer objective but preserves a native +three-valued selector. The strict tapscript executor reports +`executed_opcodes=0` because its legacy opcode counter is unavailable in +tapscript; the experiment therefore leaves that metric unclaimed. No raw +private seed is part of the public fixture. + +## Falsification attempts + +Focused tests cover all codewords, integer boundaries, wrong openings, padded +encodings, and an out-of-range trit. The local strict executor accepts the +valid fixtures and rejects those malformed witnesses. Bitcoin Core differential +validation and policy testing remain open. + +## Conclusion and knowledge updates + +The hypothesis survived the local correctness boundary. The implementation, +metrics, comparison, catalog, negative result, and open problem are updated; +the construction remains experimental and unclassified for deployment. diff --git a/src/commitments/README.md b/src/commitments/README.md index 735eadf9..4c17ee7e 100644 --- a/src/commitments/README.md +++ b/src/commitments/README.md @@ -1,6 +1,6 @@ # Integer commitments -This module contains three constructions that authenticate a small integer and +This module contains four constructions that authenticate a small integer and return it to the surrounding Bitcoin Script. They are commitment primitives, not general-purpose hash functions. @@ -10,10 +10,12 @@ not general-purpose hash functions. integer. - **Four-way hash path:** two bits select one of four fixed-length hash codewords per base-4 digit, reducing witness items and peak stack usage. +- **Ternary hash path:** three canonical trits select three fixed-length + mixed-hash codewords and reconstruct a base-3 integer. - **Preimage length:** a SHA-256 preimage is authenticated and its byte length, minus a public offset, becomes the committed integer. -All three are experimental. In particular, a hash-path commitment is +All four are experimental. In particular, a hash-path commitment is deterministic and does not hide an opening when both its preimage and bits come from small enumerable spaces. @@ -55,6 +57,19 @@ from small enumerable spaces. integer verifier hashes and reconstructs them most-significant first in one pass. There is no default variant. +### Ternary hash path + +- `bit_width`: required, with integer reconstruction limited to `1..=31`. + The adapter uses the smallest fixed number of base-3 trits that covers the + full width (`20` trits at 31 bits). +- `preimage`: caller-chosen byte string subject to the same secrecy and + 520-byte stack-element obligations as the binary and four-way paths. +- `commitment`: 20 bytes. The canonical codewords are `0 -> SS`, `1 -> SR`, + and `2 -> RS`, leaving `RR` unused. Non-canonical and out-of-range trit + encodings are rejected explicitly. +- The integer verifier hashes least-significant trits first, then reconstructs + the base-3 value. There is no default variant. + ## Rolling composition without byte concatenation Write SHA-256 as `S`, RIPEMD-160 as `R`, and define `H(0, x) = S(x)` and @@ -132,8 +147,14 @@ the tests with the listed witness. | --- | ---: | ---: | ---: | | `verify_hash_path_to_integer(31, commitment)` | 520 bytes | 78 bytes (32-byte nonce, 31 bits) | 34 | | `verify_four_way_hash_path_to_integer(31, commitment)` | 438 bytes | 61 bytes (32-byte nonce, 16 digits) | 19 | +| `verify_ternary_hash_path_to_integer(31, commitment)` | 924 bytes | 63 bytes (32-byte nonce, 20 trits) | 24 | | `verify_preimage_length(commitment)` | 44 bytes | 18–524 bytes (16–520-byte preimage) | 3 | +The benchmark executes the representative witness under the strict local +tapscript-context executor. Its `opcode_count` reports `0` because that +interpreter counter covers legacy execution and is unavailable for tapscript; +no executed-opcode total is claimed for this fragment. + ## Security The hash path ends in a 160-bit digest, capping generic collision resistance at @@ -154,6 +175,12 @@ double-hash opcodes are compositions of the former primitives. The fixed-length code avoids those structural aliases but is still a non-standard construction without a dedicated cryptanalysis. +The ternary path uses three of the four fixed two-hash codewords. Its explicit +canonical trit checks prevent alternate byte encodings from selecting the same +digit, but the construction remains a non-standard mixed-hash scheme without +dedicated cryptanalysis. For ordinary 31-bit integer metrics it is expected to +lose to the four-way path; its purpose is a native three-valued state encoding. + The preimage-length construction uses SHA-256, giving generic 128-bit collision resistance and 256-bit preimage/second-preimage resistance. Its hiding property depends on unpredictable preimage bytes; length alone is not secret once the @@ -173,6 +200,9 @@ more restrictive: its compact range proof relies on tapscript's consensus-enforced `MINIMALIF`. It is unsafe under legacy or P2WSH consensus semantics without adding explicit range checks, even though every emitted opcode exists there. Both measured 31-bit variants are tapscript-only. +The ternary path performs its own exact trit checks, so it does not rely on +`MINIMALIF`; its measured 31-bit fragment is still evaluated only in the local +tapscript-context executor. Tapscript still enforces the 1,000-item combined stack limit, the 520-byte per-item limit, witness weight, and execution budget. @@ -183,7 +213,7 @@ it with a predicate that leaves one truthy cleanstack item. See ## Witness and hints -Neither construction uses arithmetic hints. +These constructions use no arithmetic hints. For the integer hash path, witness serialization order is `bitN-1, ..., bit0, preimage`; the preimage is therefore on top at script entry. A false bit @@ -199,6 +229,12 @@ setting rejects non-minimal test witnesses. Numeric minimality is not itself a tapscript consensus rule, so callers must treat the digit value, rather than a unique byte representation, as committed. +For the ternary integer path, witness order is `most_significant_trit`, ..., +`least_significant_trit`, `preimage`. Zero is the empty vector and `1`/`2` are +exactly `[01]`/`[02]`; padded, negative-zero, and other encodings are rejected. +The helper produces this canonical encoding and the verifier reconstructs the +integer in base 3. + For the preimage-length construction, the witness contains the committed preimage as one item. The preimage is consumed and only the resulting integer remains. @@ -220,6 +256,9 @@ remains. - `verify_four_way_hash_path`: `... digitN-1 ... digit0 preimage -> ... true`. - `verify_four_way_hash_path_to_altstack` leaves digit `N-1` on top of the altstack. +- `verify_ternary_hash_path_to_integer`: `... tritN-1 ... trit0 preimage -> + ... value`, with the trits restored from the altstack during reconstruction. +- `verify_ternary_hash_path`: `... tritN-1 ... trit0 preimage -> ... true`. - `verify_preimage_length`: `... preimage -> ... length_minus_offset`. The hash-path construction generalizes the former fixed-width `BitHash128` diff --git a/src/commitments/mod.rs b/src/commitments/mod.rs index 6ad9f226..0383e180 100644 --- a/src/commitments/mod.rs +++ b/src/commitments/mod.rs @@ -3,6 +3,7 @@ pub mod four_way_hash_path; pub mod hash_path; pub mod preimage_length; +pub mod ternary_hash_path; pub use four_way_hash_path::{ four_way_hash_path_commitment, four_way_hash_path_integer_commitment, @@ -18,3 +19,8 @@ pub use preimage_length::{ preimage_length_commitment, verify_preimage_length, verify_preimage_length_with_offset, DEFAULT_PREIMAGE_LENGTH_OFFSET, MAX_PREIMAGE_LENGTH, }; +pub use ternary_hash_path::{ + ternary_hash_path_commitment, ternary_hash_path_integer_commitment, + ternary_hash_path_integer_witness, ternary_hash_path_script, ternary_hash_path_witness, + verify_ternary_hash_path, verify_ternary_hash_path_to_integer, +}; diff --git a/src/commitments/ternary_hash_path.rs b/src/commitments/ternary_hash_path.rs new file mode 100644 index 00000000..69c4bdd8 --- /dev/null +++ b/src/commitments/ternary_hash_path.rs @@ -0,0 +1,279 @@ +//! Ternary hash-path commitments using fixed-length SHA-256/RIPEMD-160 codewords. +//! +//! The three codewords are `0 -> SS`, `1 -> SR`, and `2 -> RS`. The unused +//! `RR` codeword keeps every trit at exactly two hashes while leaving a +//! canonical three-valued selector. + +use bitcoin::hashes::{ripemd160, sha256, Hash}; + +use crate::support::script::{script, Script}; + +use super::hash_path::MAX_INTEGER_BITS; + +/// Compute a ternary hash-path commitment for least-significant-first trits. +pub fn ternary_hash_path_commitment(preimage: &[u8], trits: &[u8]) -> [u8; 20] { + let mut state = preimage.to_vec(); + for &trit in trits { + assert!(trit < 3, "ternary hash-path trits must be in 0..=2"); + state = match trit { + 0 => sha256::Hash::hash(&sha256::Hash::hash(&state).to_byte_array()) + .to_byte_array() + .to_vec(), + 1 => ripemd160::Hash::hash(&sha256::Hash::hash(&state).to_byte_array()) + .to_byte_array() + .to_vec(), + 2 => sha256::Hash::hash(&ripemd160::Hash::hash(&state).to_byte_array()) + .to_byte_array() + .to_vec(), + _ => unreachable!(), + }; + } + ripemd160::Hash::hash(&state).to_byte_array() +} + +/// Compute a ternary commitment to a `bit_width`-bit integer. +pub fn ternary_hash_path_integer_commitment( + preimage: &[u8], + value: u32, + bit_width: usize, +) -> [u8; 20] { + let trits = integer_trits(value, bit_width); + ternary_hash_path_commitment(preimage, &trits) +} + +/// Build the canonical witness for [`verify_ternary_hash_path_to_integer`]. +/// +/// The witness order is most-significant trit first, then `preimage`; the +/// verifier's first `OP_SWAP` activates the least-significant trit. +pub fn ternary_hash_path_integer_witness( + preimage: &[u8], + value: u32, + bit_width: usize, +) -> Vec> { + let trits = integer_trits(value, bit_width); + ternary_hash_path_witness(preimage, &trits) +} + +/// Build a canonical witness for a generic ternary path. +pub fn ternary_hash_path_witness(preimage: &[u8], trits: &[u8]) -> Vec> { + let mut witness = trits + .iter() + .rev() + .map(|&trit| { + assert!(trit < 3, "ternary hash-path trits must be in 0..=2"); + match trit { + 0 => vec![], + 1 | 2 => vec![trit], + _ => unreachable!(), + } + }) + .collect::>(); + witness.push(preimage.to_vec()); + witness +} + +fn integer_trits(value: u32, bit_width: usize) -> Vec { + assert_integer_width(bit_width); + assert!( + value < (1u32 << bit_width), + "value does not fit in bit_width" + ); + let mut value = u64::from(value); + let mut trits = Vec::with_capacity(integer_trit_count(bit_width)); + for _ in 0..integer_trit_count(bit_width) { + trits.push((value % 3) as u8); + value /= 3; + } + assert_eq!(value, 0); + trits +} + +fn integer_trit_count(bit_width: usize) -> usize { + let limit = 1u64 << bit_width; + let mut capacity = 1u64; + let mut count = 0; + while capacity < limit { + capacity *= 3; + count += 1; + } + count +} + +fn assert_integer_width(bit_width: usize) { + assert!( + (1..=MAX_INTEGER_BITS).contains(&bit_width), + "bit_width must be in 1..={MAX_INTEGER_BITS}" + ); +} + +fn certify_trit() -> Script { + script! { + OP_DUP + OP_0 + OP_EQUAL + OP_IF + OP_SIZE + OP_0 + OP_EQUALVERIFY + OP_ELSE + OP_DUP + 1 + OP_EQUAL + OP_IF + OP_ELSE + OP_DUP + 2 + OP_EQUALVERIFY + OP_ENDIF + OP_ENDIF + } +} + +fn ternary_hash_path_script_inner(trit_count: usize, save_trits: bool) -> Script { + assert!(trit_count > 0, "trit_count must be non-zero"); + script! { + for _ in 0..trit_count { + OP_SWAP + { certify_trit() } + + if save_trits { + OP_DUP + OP_TOALTSTACK + } + + OP_DUP + 2 + OP_LESSTHAN + OP_IF + OP_SWAP + OP_SHA256 + OP_SWAP + OP_IF + OP_RIPEMD160 + OP_ELSE + OP_SHA256 + OP_ENDIF + OP_ELSE + 2 + OP_EQUALVERIFY + OP_RIPEMD160 + OP_SHA256 + OP_ENDIF + } + OP_RIPEMD160 + } +} + +/// Compute a ternary hash path from a preimage and least-significant-first trits. +pub fn ternary_hash_path_script(trit_count: usize) -> Script { + ternary_hash_path_script_inner(trit_count, false) +} + +/// Verify a generic ternary hash path and leave true. +pub fn verify_ternary_hash_path(trit_count: usize, commitment: [u8; 20]) -> Script { + script! { + { ternary_hash_path_script(trit_count) } + { commitment.to_vec() } + OP_EQUALVERIFY + OP_1 + } +} + +/// Verify a ternary path and reconstruct its committed integer. +pub fn verify_ternary_hash_path_to_integer(bit_width: usize, commitment: [u8; 20]) -> Script { + assert_integer_width(bit_width); + let trit_count = integer_trit_count(bit_width); + script! { + { ternary_hash_path_script_inner(trit_count, true) } + { commitment.to_vec() } + OP_EQUALVERIFY + + 0 + for _ in 0..trit_count { + OP_FROMALTSTACK + OP_SWAP + OP_DUP + OP_DUP + OP_ADD + OP_ADD + OP_ADD + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::support::execution::execute_script_with_inputs_strict; + + #[test] + fn verifies_all_ternary_codewords() { + let preimage = b"ternary nonce"; + for trit in 0..3 { + let trits = [trit]; + let commitment = ternary_hash_path_commitment(preimage, &trits); + let result = execute_script_with_inputs_strict( + verify_ternary_hash_path(1, commitment), + ternary_hash_path_witness(preimage, &trits), + ); + assert!(result.success, "trit={trit}: {result}"); + } + } + + #[test] + fn verifies_integer_boundaries_and_values() { + for (value, width) in [ + (0, 1), + (1, 1), + (2, 2), + (4, 3), + (0x55, 7), + (0x1234_5678, 31), + (u32::MAX >> 1, 31), + ] { + let preimage = [0x42; 32]; + let commitment = ternary_hash_path_integer_commitment(&preimage, value, width); + let result = execute_script_with_inputs_strict( + script! { + { verify_ternary_hash_path_to_integer(width, commitment) } + { value } + OP_EQUAL + }, + ternary_hash_path_integer_witness(&preimage, value, width), + ); + assert!(result.success, "value={value}, width={width}: {result}"); + } + } + + #[test] + fn rejects_wrong_openings_and_noncanonical_trits() { + let preimage = [0x11; 32]; + let commitment = ternary_hash_path_integer_commitment(&preimage, 17, 6); + let wrong_value = ternary_hash_path_integer_witness(&preimage, 18, 6); + let result = execute_script_with_inputs_strict( + script! { { verify_ternary_hash_path_to_integer(6, commitment) } OP_DROP OP_1 }, + wrong_value, + ); + assert!(!result.success); + + let mut noncanonical = ternary_hash_path_integer_witness(&preimage, 17, 6); + noncanonical[0] = vec![2, 0]; + let result = execute_script_with_inputs_strict( + script! { { verify_ternary_hash_path_to_integer(6, commitment) } OP_DROP OP_1 }, + noncanonical, + ); + assert!(!result.success); + } + + #[test] + fn rejects_out_of_range_generic_trits() { + let preimage = b"bad trit"; + let commitment = ternary_hash_path_commitment(preimage, &[0, 1, 2]); + let witness = vec![vec![3], vec![], vec![], preimage.to_vec()]; + let result = execute_script_with_inputs_strict( + script! { { verify_ternary_hash_path(3, commitment) } OP_DROP OP_1 }, + witness, + ); + assert!(!result.success); + } +} diff --git a/tests/primitive_metrics.rs b/tests/primitive_metrics.rs index 581dbce2..8f77225c 100644 --- a/tests/primitive_metrics.rs +++ b/tests/primitive_metrics.rs @@ -15,7 +15,9 @@ use bitcoin_lab::{ commitments::{ four_way_hash_path_integer_commitment, four_way_hash_path_integer_witness, hash_path_integer_commitment, hash_path_integer_witness, preimage_length_commitment, + ternary_hash_path_integer_commitment, ternary_hash_path_integer_witness, verify_four_way_hash_path_to_integer, verify_hash_path_to_integer, verify_preimage_length, + verify_ternary_hash_path_to_integer, }, curves::bn254::groups::{g1::G1Affine, g2::G2Affine}, fields::{ @@ -1850,6 +1852,11 @@ fn metrics() -> Vec { let four_way_hash_path_witness = four_way_hash_path_integer_witness(&hash_path_preimage, hash_path_value, 31); + let ternary_hash_path_commitment = + ternary_hash_path_integer_commitment(&hash_path_preimage, hash_path_value, 31); + let ternary_hash_path_witness = + ternary_hash_path_integer_witness(&hash_path_preimage, hash_path_value, 31); + let length_preimage = vec![0x24; 32]; let length_commitment = preimage_length_commitment(&length_preimage); @@ -3722,6 +3729,27 @@ fn metrics() -> Vec { four_way_hash_path_witness, ), }, + Metric { + readme: "src/commitments/README.md", + key: "ternary_hash_path_integer_31", + value: script_len(verify_ternary_hash_path_to_integer( + 31, + ternary_hash_path_commitment, + )), + }, + Metric { + readme: "src/commitments/README.md", + key: "ternary_hash_path_integer_witness_31", + value: witness_size(&ternary_hash_path_witness), + }, + Metric { + readme: "src/commitments/README.md", + key: "ternary_hash_path_integer_stack_31", + value: max_stack_items( + verify_ternary_hash_path_to_integer(31, ternary_hash_path_commitment), + ternary_hash_path_witness, + ), + }, Metric { readme: "src/commitments/README.md", key: "preimage_length_default", @@ -4020,6 +4048,31 @@ fn winternitz_metrics_are_current() { ); } +#[test] +fn ternary_hash_path_metrics_are_current() { + let preimage = vec![0x42; 32]; + let value = 0x1234_5678; + let commitment = ternary_hash_path_integer_commitment(&preimage, value, 31); + let witness = ternary_hash_path_integer_witness(&preimage, value, 31); + check_readme_metrics(vec![ + Metric { + readme: "src/commitments/README.md", + key: "ternary_hash_path_integer_31", + value: script_len(verify_ternary_hash_path_to_integer(31, commitment)), + }, + Metric { + readme: "src/commitments/README.md", + key: "ternary_hash_path_integer_witness_31", + value: witness_size(&witness), + }, + Metric { + readme: "src/commitments/README.md", + key: "ternary_hash_path_integer_stack_31", + value: max_stack_items(verify_ternary_hash_path_to_integer(31, commitment), witness), + }, + ]); +} + #[test] fn winternitz20_composition_metrics_are_current() { check_readme_metrics(winternitz20_composition_metrics()); From 59443b5bc5997138f29e5d719b7d950b29ba8328 Mon Sep 17 00:00:00 2001 From: Breno Brito Date: Fri, 18 Sep 2026 13:15:32 -0300 Subject: [PATCH 2/4] fix(commitments): enforce ternary integer width --- knowledge/catalog.json | 9 +- .../primitives/ternary-hash-path-integer.md | 10 +- src/commitments/README.md | 5 +- src/commitments/ternary_hash_path.rs | 96 ++++++++++++++++++- 4 files changed, 109 insertions(+), 11 deletions(-) diff --git a/knowledge/catalog.json b/knowledge/catalog.json index 5198f5cf..706b364f 100644 --- a/knowledge/catalog.json +++ b/knowledge/catalog.json @@ -1514,6 +1514,9 @@ "tests": [ "commitments::ternary_hash_path::tests::verifies_all_ternary_codewords", "commitments::ternary_hash_path::tests::verifies_integer_boundaries_and_values", + "commitments::ternary_hash_path::tests::enforces_integer_width_at_every_supported_width", + "commitments::ternary_hash_path::tests::preserves_surrounding_main_and_alt_stack_state", + "commitments::ternary_hash_path::tests::rejects_scriptnum_overflow_during_reconstruction", "commitments::ternary_hash_path::tests::rejects_wrong_openings_and_noncanonical_trits", "commitments::ternary_hash_path::tests::rejects_out_of_range_generic_trits", "primitive_metrics::ternary_hash_path_metrics_are_current", @@ -1540,14 +1543,14 @@ "preimage_bytes": 32 }, "includes": "fragment-only: verifier and base-3 integer reconstruction; witness includes serialized trits and preimage", - "script_bytes": 924, + "script_bytes": 947, "witness_bytes": 63, "witness_bytes_max": 63, "max_stack_items": 24, "executed_opcodes": null, "validation_weight": null, "setup_script_bytes": 0, - "per_use_script_bytes": 924, + "per_use_script_bytes": 947, "metric_keys": [ "ternary_hash_path_integer_31", "ternary_hash_path_integer_witness_31", @@ -1557,7 +1560,7 @@ ], "limitations": [ "Non-standard mixed-hash construction without dedicated cryptanalysis", - "Integer reconstruction limited to 31 bits and 20 trits at that width", + "Integer reconstruction limited to 31 bits and 20 trits at that width; out-of-range values are rejected", "Dominated by the measured four-way path for ordinary 31-bit integer bytes and stack usage", "All trits coexist at script entry; surrounding protocol state must be charged against the 1,000-item stack limit", "Bitcoin Core consensus and policy validation not performed" diff --git a/knowledge/primitives/ternary-hash-path-integer.md b/knowledge/primitives/ternary-hash-path-integer.md index 066995bb..a1579388 100644 --- a/knowledge/primitives/ternary-hash-path-integer.md +++ b/knowledge/primitives/ternary-hash-path-integer.md @@ -27,12 +27,14 @@ requested width; 31 bits require 20 trits. Witness order is other trits as exactly `[01]` or `[02]`. The Script fragment explicitly rejects padded, negative-zero, and out-of-range -trit encodings. It then reconstructs the committed value as -`3*acc + trit` while draining the saved trits from the altstack. +trit encodings. The integer adapter also rejects values outside the requested +bit width before the final `3*acc + trit` step. It then reconstructs the +committed value while draining the saved trits from the altstack. ## Evidence and representative cost -Evidence is `locally-reproduced`: all three codewords, integer boundaries, +Evidence is `locally-reproduced`: all three codewords, integer boundaries at +every supported width, surrounding-stack preservation, ScriptNum overflow, wrong openings, non-canonical encodings, and out-of-range trits pass focused tests. The local tests use the strict tapscript-context executor; no Bitcoin Core consensus or relay-policy comparison has been performed, so deployment is @@ -42,7 +44,7 @@ For a 32-byte preimage and a 31-bit value: | Fragment | Script bytes | Serialized witness | Witness items | Peak items | | --- | ---: | ---: | ---: | ---: | -| `verify_ternary_hash_path_to_integer` | 924 | 63 | 21 | 24 | +| `verify_ternary_hash_path_to_integer` | 947 | 63 | 21 | 24 | The benchmark reports zero auxiliary hint items. These are fragment-only measurements: the verifier and integer reconstruction are included, while diff --git a/src/commitments/README.md b/src/commitments/README.md index 4c17ee7e..8a794f63 100644 --- a/src/commitments/README.md +++ b/src/commitments/README.md @@ -68,7 +68,8 @@ from small enumerable spaces. and `2 -> RS`, leaving `RR` unused. Non-canonical and out-of-range trit encodings are rejected explicitly. - The integer verifier hashes least-significant trits first, then reconstructs - the base-3 value. There is no default variant. + the base-3 value and rejects values outside the requested bit width. There is + no default variant. ## Rolling composition without byte concatenation @@ -147,7 +148,7 @@ the tests with the listed witness. | --- | ---: | ---: | ---: | | `verify_hash_path_to_integer(31, commitment)` | 520 bytes | 78 bytes (32-byte nonce, 31 bits) | 34 | | `verify_four_way_hash_path_to_integer(31, commitment)` | 438 bytes | 61 bytes (32-byte nonce, 16 digits) | 19 | -| `verify_ternary_hash_path_to_integer(31, commitment)` | 924 bytes | 63 bytes (32-byte nonce, 20 trits) | 24 | +| `verify_ternary_hash_path_to_integer(31, commitment)` | 947 bytes | 63 bytes (32-byte nonce, 20 trits) | 24 | | `verify_preimage_length(commitment)` | 44 bytes | 18–524 bytes (16–520-byte preimage) | 3 | The benchmark executes the representative witness under the strict local diff --git a/src/commitments/ternary_hash_path.rs b/src/commitments/ternary_hash_path.rs index 69c4bdd8..743e2a0d 100644 --- a/src/commitments/ternary_hash_path.rs +++ b/src/commitments/ternary_hash_path.rs @@ -179,18 +179,34 @@ pub fn verify_ternary_hash_path(trit_count: usize, commitment: [u8; 20]) -> Scri } } -/// Verify a ternary path and reconstruct its committed integer. +/// Verify a ternary path and reconstruct its committed integer, rejecting +/// values outside the requested bit width. pub fn verify_ternary_hash_path_to_integer(bit_width: usize, commitment: [u8; 20]) -> Script { assert_integer_width(bit_width); let trit_count = integer_trit_count(bit_width); + let maximum = (1u64 << bit_width) - 1; + let quotient = (maximum / 3) as u32; + let remainder = (maximum % 3) as u32; script! { { ternary_hash_path_script_inner(trit_count, true) } { commitment.to_vec() } OP_EQUALVERIFY 0 - for _ in 0..trit_count { + for step in 0..trit_count { OP_FROMALTSTACK + if step + 1 == trit_count { + // Before the final 3*acc + trit step, enforce acc*3+trit <= 2^width-1. + OP_SWAP + OP_DUP { quotient } OP_LESSTHANOREQUAL OP_VERIFY + OP_DUP { quotient } OP_EQUAL + OP_IF + OP_SWAP + OP_DUP { remainder } OP_LESSTHANOREQUAL OP_VERIFY + OP_SWAP + OP_ENDIF + OP_SWAP + } OP_SWAP OP_DUP OP_DUP @@ -245,6 +261,82 @@ mod tests { } } + #[test] + fn enforces_integer_width_at_every_supported_width() { + let preimage = [0x42; 32]; + for width in 1..=31 { + let trit_count = integer_trit_count(width); + let maximum = (1u64 << width) - 1; + for value in [maximum, 1u64 << width] { + let mut remaining = value; + let trits = (0..trit_count) + .map(|_| { + let trit = (remaining % 3) as u8; + remaining /= 3; + trit + }) + .collect::>(); + let commitment = ternary_hash_path_commitment(&preimage, &trits); + let result = execute_script_with_inputs_strict( + script! { + { verify_ternary_hash_path_to_integer(width, commitment) } + OP_DROP OP_TRUE + }, + ternary_hash_path_witness(&preimage, &trits), + ); + assert_eq!( + result.success, + value == maximum, + "value={value}, width={width}: {result}" + ); + } + } + } + + #[test] + fn preserves_surrounding_main_and_alt_stack_state() { + let width = 6; + let value = 17; + let preimage = [0x24; 32]; + let commitment = ternary_hash_path_integer_commitment(&preimage, value, width); + let mut witness = vec![vec![0x7b]]; + witness.extend(ternary_hash_path_integer_witness(&preimage, value, width)); + let result = execute_script_with_inputs_strict( + script! { + 0x2a OP_TOALTSTACK + { verify_ternary_hash_path_to_integer(width, commitment) } + { value } OP_EQUALVERIFY + OP_FROMALTSTACK 0x2a OP_EQUALVERIFY + 0x7b OP_EQUAL + }, + witness, + ); + assert!( + result.success, + "surrounding stack state was not preserved: {result}" + ); + } + + #[test] + fn rejects_scriptnum_overflow_during_reconstruction() { + let width = 31; + let trit_count = integer_trit_count(width); + let trits = vec![2; trit_count]; + let preimage = [0x33; 32]; + let commitment = ternary_hash_path_commitment(&preimage, &trits); + let result = execute_script_with_inputs_strict( + script! { + { verify_ternary_hash_path_to_integer(width, commitment) } + OP_DROP OP_TRUE + }, + ternary_hash_path_witness(&preimage, &trits), + ); + assert!( + !result.success, + "overflowing reconstruction was accepted: {result}" + ); + } + #[test] fn rejects_wrong_openings_and_noncanonical_trits() { let preimage = [0x11; 32]; From 068c6a72239ff2da833bf2b7f10e145423ddcc16 Mon Sep 17 00:00:00 2001 From: Breno Brito Date: Sun, 27 Sep 2026 21:23:49 -0300 Subject: [PATCH 3/4] test(commitments): pin ternary width-1/31 rejection to the width check Add rejects_first_out_of_range_value_at_widths_1_and_31_before_overflow. For widths 1 and 31 it checks a valid 2^width-1 control and requires 2^width to be rejected with ExecError::Verify from the pre-final-step width check. At width 31 a bound applied only after the last multiply/add is rejected by ScriptNum overflow instead, which the existing all-width test cannot distinguish. The pinned interpreter (a09e87af) now counts every tapscript instruction position in opcode_count, so the benchmark's executed_opcodes=919 was a static position count, not an executed-opcode total, and the documented value 0 was stale. Report static instructions (919), static non-push opcodes (794), the interpreter position count and executed_opcodes=unavailable, and correct the README, knowledge page and research note. Co-Authored-By: Claude Opus 5.5 --- examples/ternary_hash_path_benchmark.rs | 27 ++++++++- knowledge/catalog.json | 1 + .../primitives/ternary-hash-path-integer.md | 8 ++- research/ternary-hash-path/README.md | 10 ++-- src/commitments/ternary_hash_path/README.md | 20 ++++--- src/commitments/ternary_hash_path/mod.rs | 55 +++++++++++++++++++ 6 files changed, 104 insertions(+), 17 deletions(-) diff --git a/examples/ternary_hash_path_benchmark.rs b/examples/ternary_hash_path_benchmark.rs index 56ad3cb7..fa4898ad 100644 --- a/examples/ternary_hash_path_benchmark.rs +++ b/examples/ternary_hash_path_benchmark.rs @@ -1,4 +1,5 @@ use bitcoin::consensus::encode::serialize; +use bitcoin::script::Instruction; use bitcoin::Witness; use bitcoin_lab::commitments::{ ternary_hash_path_integer_commitment, ternary_hash_path_integer_witness, @@ -15,7 +16,18 @@ fn main() { let verifier = verify_ternary_hash_path_to_integer(31, commitment); let execution = execute_script_with_inputs_strict(verifier.clone(), witness.clone()); assert!(execution.success, "benchmark fixture failed: {execution}"); - let script_bytes = verifier.compile_with_policy().len(); + let compiled = verifier.compile_with_policy(); + let script_bytes = compiled.len(); + let instructions = compiled + .instructions() + .map(|instruction| instruction.expect("generated script must parse")) + .collect::>(); + let static_non_push_opcodes = instructions + .iter() + .filter( + |instruction| matches!(instruction, Instruction::Op(opcode) if opcode.to_u8() > 0x60), + ) + .count(); println!("primitive=ternary_hash_path_integer"); println!("bit_width=31"); @@ -27,6 +39,17 @@ fn main() { ); println!("witness_items={}", witness.len()); println!("hint_items=0"); - println!("executed_opcodes={}", execution.stats.opcode_count); + println!("stack_peak={}", execution.stats.max_nb_stack_items); + println!("static_instructions={}", instructions.len()); + println!("static_non_push_opcodes={static_non_push_opcodes}"); + // In tapscript the pinned interpreter's `opcode_count` counts every + // instruction position, executed or not (OP_CODESEPARATOR positions), so + // it is not an executed-opcode measurement. + println!( + "interpreter_tapscript_position_count={}", + execution.stats.opcode_count + ); + println!("executed_opcodes=unavailable"); + println!("execution_class=unclassified"); println!("commitment_bytes={}", commitment.len()); } diff --git a/knowledge/catalog.json b/knowledge/catalog.json index 5f160d16..21e74d35 100644 --- a/knowledge/catalog.json +++ b/knowledge/catalog.json @@ -3251,6 +3251,7 @@ "commitments::ternary_hash_path::tests::verifies_all_ternary_codewords", "commitments::ternary_hash_path::tests::verifies_integer_boundaries_and_values", "commitments::ternary_hash_path::tests::enforces_integer_width_at_every_supported_width", + "commitments::ternary_hash_path::tests::rejects_first_out_of_range_value_at_widths_1_and_31_before_overflow", "commitments::ternary_hash_path::tests::preserves_surrounding_main_and_alt_stack_state", "commitments::ternary_hash_path::tests::rejects_scriptnum_overflow_during_reconstruction", "commitments::ternary_hash_path::tests::rejects_wrong_openings_and_noncanonical_trits", diff --git a/knowledge/primitives/ternary-hash-path-integer.md b/knowledge/primitives/ternary-hash-path-integer.md index e8174c07..dbe3ea98 100644 --- a/knowledge/primitives/ternary-hash-path-integer.md +++ b/knowledge/primitives/ternary-hash-path-integer.md @@ -51,9 +51,11 @@ preimage) coexist at script entry. The stack peak is measured with strict local stack checks. These are fragment-only measurements: the verifier and integer reconstruction are included, while input pushes, terminal predicates, and transaction framing are excluded. -The strict local tapscript benchmark's legacy `opcode_count` reports `0`, so -executed-opcode count remains unavailable rather than being inferred from the -static script. +The script has 919 static instructions, 794 of them static non-push opcodes +(inactive branches included). The pinned interpreter's tapscript +`opcode_count` counts every instruction position, executed or not, so it also +reports 919; executed-opcode count remains unavailable rather than being +inferred from either static count. The construction is larger than the measured four-way path (438 bytes, 61 witness bytes, 19 peak items) for ordinary 31-bit integers. Its value is the diff --git a/research/ternary-hash-path/README.md b/research/ternary-hash-path/README.md index 795a7116..be882cb3 100644 --- a/research/ternary-hash-path/README.md +++ b/research/ternary-hash-path/README.md @@ -37,10 +37,12 @@ Bitcoin witness serialization framing. There are zero auxiliary hint items. The 31-bit representative is 947 policy-produced script bytes, 63 serialized witness bytes, 21 witness items, and a 24-item combined local peak. It is larger than the four-way path for this integer objective but preserves a native -three-valued selector. The strict tapscript executor reports -`executed_opcodes=0` because its legacy opcode counter is unavailable in -tapscript; the experiment therefore leaves that metric unclaimed. No raw -private seed is part of the public fixture. +three-valued selector. The benchmark reports 919 static instructions and 794 +static non-push opcodes. At interpreter pin `a09e87af`, the tapscript +`opcode_count` statistic counts every instruction position (also 919), not +executed opcodes, so the benchmark prints `executed_opcodes=unavailable` and +the experiment leaves that metric unclaimed. No raw private seed is part of the +public fixture. ## Falsification attempts diff --git a/src/commitments/ternary_hash_path/README.md b/src/commitments/ternary_hash_path/README.md index b6306db7..b8655a66 100644 --- a/src/commitments/ternary_hash_path/README.md +++ b/src/commitments/ternary_hash_path/README.md @@ -27,10 +27,12 @@ executor with the 1,000-item stack limit enabled; deployment is | --- | ---: | ---: | ---: | ---: | | `verify_ternary_hash_path_to_integer(31, commitment)` | 947 bytes | 63 bytes (32-byte nonce, 20 trits, 21 data items) | 0 (none) | 24 | -The benchmark example executes the same representative witness. Its -`opcode_count` reports `0` because that interpreter counter covers legacy -execution and is unavailable for tapscript; no executed-opcode total is -claimed for this fragment. +The benchmark example executes the same representative witness and reports +919 static instructions, 794 of them static non-push opcodes (inactive branches +included). At interpreter pin `a09e87af444034698697f0a2267e755cf72f9aed`, the +tapscript `opcode_count` statistic also reports 919 because it counts every +instruction position, executed or not; it is not an executed-opcode total, and +none is claimed for this fragment. ## Security @@ -75,10 +77,12 @@ Before the final `3*acc + trit` step, the integer adapter checks `acc <= floor((2^width-1)/3)` and, when equal, `trit <= (2^width-1) mod 3`, so out-of-range values are rejected before any value wider than the declared integer is produced. Tests cover every codeword, integer boundaries, the -`2^width-1` acceptance and `2^width` rejection at every width `1..=31`, -surrounding-stack preservation, ScriptNum overflow, wrong openings, padded -trits and out-of-range generic trits. The construction is dominated by the -four-way path for ordinary 31-bit integers (NR-072). +`2^width-1` acceptance and `2^width` rejection at every width `1..=31` (widths +1 and 31 additionally require the width check's `OP_VERIFY`, not a later +ScriptNum overflow, to reject), surrounding-stack preservation, ScriptNum +overflow, wrong openings, padded trits and out-of-range generic trits. The +construction is dominated by the four-way path for ordinary 31-bit integers +(NR-072). ## Knowledge-base integration diff --git a/src/commitments/ternary_hash_path/mod.rs b/src/commitments/ternary_hash_path/mod.rs index 743e2a0d..c947df3c 100644 --- a/src/commitments/ternary_hash_path/mod.rs +++ b/src/commitments/ternary_hash_path/mod.rs @@ -293,6 +293,61 @@ mod tests { } } + /// Least-significant-first trits for `value`, without the host-side + /// width assertion, so tests can commit to out-of-range values. + fn unchecked_integer_trits(value: u64, bit_width: usize) -> Vec { + let mut remaining = value; + let trits = (0..integer_trit_count(bit_width)) + .map(|_| { + let trit = (remaining % 3) as u8; + remaining /= 3; + trit + }) + .collect(); + assert_eq!(remaining, 0, "value needs more trits than bit_width allows"); + trits + } + + #[test] + fn rejects_first_out_of_range_value_at_widths_1_and_31_before_overflow() { + let preimage = [0x42; 32]; + for width in [1, 31] { + let maximum = (1u64 << width) - 1; + + // Valid control: the largest in-range value reconstructs exactly. + let trits = unchecked_integer_trits(maximum, width); + let commitment = ternary_hash_path_commitment(&preimage, &trits); + let result = execute_script_with_inputs_strict( + script! { + { verify_ternary_hash_path_to_integer(width, commitment) } + { maximum as u32 } + OP_EQUAL + }, + ternary_hash_path_witness(&preimage, &trits), + ); + assert!(result.success, "control width={width}: {result}"); + + // 2^width has a valid opening, but the width check's OP_VERIFY + // must reject it before the final 3*acc + trit step; at width 31 + // a post-reconstruction check would instead hit ScriptNum overflow. + let trits = unchecked_integer_trits(maximum + 1, width); + let commitment = ternary_hash_path_commitment(&preimage, &trits); + let result = execute_script_with_inputs_strict( + script! { + { verify_ternary_hash_path_to_integer(width, commitment) } + OP_DROP OP_TRUE + }, + ternary_hash_path_witness(&preimage, &trits), + ); + assert!(!result.success, "2^{width} was accepted: {result}"); + assert_eq!( + result.error, + Some(bitcoin_scriptexec::ExecError::Verify), + "2^{width} was not rejected by the width check: {result}" + ); + } + } + #[test] fn preserves_surrounding_main_and_alt_stack_state() { let width = 6; From e2e58fa0a0f6538b7906713ffc8238a2e45f7753 Mon Sep 17 00:00:00 2001 From: Breno Brito Date: Mon, 28 Sep 2026 06:28:14 -0300 Subject: [PATCH 4/4] test(commitments): cover both ternary width-check branches The width tests only rejected 2^width. Since 2^width mod 3 is 1 or 2, its final-step accumulator always equals the quotient, so only the remainder branch was exercised: deleting the `acc <= q` check left all tests passing and accepted value 6 at width 2. Add rejects_out_of_range_values_on_both_width_check_branches_at_every_width. For every width 1..=31 it runs a valid 2^width-1 control and requires ExecError::Verify for 58 accumulator-above-quotient values ((q+1)*3 and 3^t-1; the branch is unreachable at widths 1 and 3) and 46 final-trit-above-remainder values. Update the catalog test list, README, knowledge page and research note. Co-Authored-By: Claude Opus 5.5 --- knowledge/catalog.json | 1 + .../primitives/ternary-hash-path-integer.md | 8 +- research/ternary-hash-path/README.md | 10 ++- src/commitments/ternary_hash_path/README.md | 9 ++- src/commitments/ternary_hash_path/mod.rs | 75 +++++++++++++++++++ 5 files changed, 93 insertions(+), 10 deletions(-) diff --git a/knowledge/catalog.json b/knowledge/catalog.json index a4a83147..c722cd35 100644 --- a/knowledge/catalog.json +++ b/knowledge/catalog.json @@ -3788,6 +3788,7 @@ "commitments::ternary_hash_path::tests::verifies_integer_boundaries_and_values", "commitments::ternary_hash_path::tests::enforces_integer_width_at_every_supported_width", "commitments::ternary_hash_path::tests::rejects_first_out_of_range_value_at_widths_1_and_31_before_overflow", + "commitments::ternary_hash_path::tests::rejects_out_of_range_values_on_both_width_check_branches_at_every_width", "commitments::ternary_hash_path::tests::preserves_surrounding_main_and_alt_stack_state", "commitments::ternary_hash_path::tests::rejects_scriptnum_overflow_during_reconstruction", "commitments::ternary_hash_path::tests::rejects_wrong_openings_and_noncanonical_trits", diff --git a/knowledge/primitives/ternary-hash-path-integer.md b/knowledge/primitives/ternary-hash-path-integer.md index dbe3ea98..7e6bf07e 100644 --- a/knowledge/primitives/ternary-hash-path-integer.md +++ b/knowledge/primitives/ternary-hash-path-integer.md @@ -34,9 +34,11 @@ committed value while draining the saved trits from the altstack. ## Evidence and representative cost Evidence is `locally-reproduced`: all three codewords, integer boundaries at -every supported width, surrounding-stack preservation, ScriptNum overflow, -wrong openings, non-canonical encodings, and out-of-range trits pass focused -tests. The local tests use the strict tapscript-context executor; no Bitcoin +every supported width, out-of-range rejections on both branches of the +integer-width check (accumulator above the quotient, and equal to it with a +final trit above the remainder), surrounding-stack preservation, ScriptNum +overflow, wrong openings, non-canonical encodings, and out-of-range trits pass +focused tests. The local tests use the strict tapscript-context executor; no Bitcoin Core consensus or relay-policy comparison has been performed, so deployment is `unclassified`. diff --git a/research/ternary-hash-path/README.md b/research/ternary-hash-path/README.md index be882cb3..9448c923 100644 --- a/research/ternary-hash-path/README.md +++ b/research/ternary-hash-path/README.md @@ -47,10 +47,12 @@ public fixture. ## Falsification attempts Focused tests cover all codewords, integer boundaries, `2^width-1` acceptance -and `2^width` rejection at every width `1..=31`, surrounding-stack -preservation, ScriptNum overflow, wrong openings, padded encodings, and an -out-of-range trit. The local strict executor accepts the -valid fixtures and rejects those malformed witnesses. Bitcoin Core differential +and `2^width` rejection at every width `1..=31`, rejection on both branches +of the integer-width check (58 accumulator-above-quotient and 46 +final-trit-above-remainder values), surrounding-stack preservation, ScriptNum +overflow, wrong openings, padded encodings, and an out-of-range trit. The local +strict executor accepts the valid fixtures and rejects those malformed +witnesses. Bitcoin Core differential validation and policy testing remain open. ## Conclusion and knowledge updates diff --git a/src/commitments/ternary_hash_path/README.md b/src/commitments/ternary_hash_path/README.md index b8655a66..f703540f 100644 --- a/src/commitments/ternary_hash_path/README.md +++ b/src/commitments/ternary_hash_path/README.md @@ -80,9 +80,12 @@ integer is produced. Tests cover every codeword, integer boundaries, the `2^width-1` acceptance and `2^width` rejection at every width `1..=31` (widths 1 and 31 additionally require the width check's `OP_VERIFY`, not a later ScriptNum overflow, to reject), surrounding-stack preservation, ScriptNum -overflow, wrong openings, padded trits and out-of-range generic trits. The -construction is dominated by the four-way path for ordinary 31-bit integers -(NR-072). +overflow, wrong openings, padded trits and out-of-range generic trits. Both +branches of the width check are exercised at every width with an `OP_VERIFY` +rejection: 58 values whose final-step accumulator exceeds the quotient +(`(q+1)*3` and `3^t-1`; unreachable at widths 1 and 3) and 46 values whose +accumulator equals it with a final trit above the remainder. The construction +is dominated by the four-way path for ordinary 31-bit integers (NR-072). ## Knowledge-base integration diff --git a/src/commitments/ternary_hash_path/mod.rs b/src/commitments/ternary_hash_path/mod.rs index c947df3c..7eae619c 100644 --- a/src/commitments/ternary_hash_path/mod.rs +++ b/src/commitments/ternary_hash_path/mod.rs @@ -348,6 +348,81 @@ mod tests { } } + #[test] + fn rejects_out_of_range_values_on_both_width_check_branches_at_every_width() { + let preimage = [0x42; 32]; + let (mut quotient_rejections, mut remainder_rejections) = (0, 0); + for width in 1..=31 { + let trit_count = integer_trit_count(width) as u32; + let maximum = (1u64 << width) - 1; + let (quotient, remainder) = (maximum / 3, maximum % 3); + let capacity = 3u64.pow(trit_count); + + // Valid control: the largest in-range value reconstructs exactly. + let trits = unchecked_integer_trits(maximum, width); + let commitment = ternary_hash_path_commitment(&preimage, &trits); + let result = execute_script_with_inputs_strict( + script! { + { verify_ternary_hash_path_to_integer(width, commitment) } + { maximum as u32 } + OP_EQUAL + }, + ternary_hash_path_witness(&preimage, &trits), + ); + assert!(result.success, "control width={width}: {result}"); + + // Quotient branch: accumulator before the final step exceeds q. + // Candidates are (q+1)*3 and the all-2 path 3^t-1. Widths 1 and 3 + // have no t-trit value with such an accumulator (3^(t-1)-1 <= q). + let mut quotient_values = vec![(quotient + 1) * 3, capacity - 1]; + quotient_values.retain(|&value| value < capacity && value / 3 > quotient); + assert_eq!(capacity / 3 - 1 <= quotient, matches!(width, 1 | 3)); + assert_eq!( + quotient_values.is_empty(), + matches!(width, 1 | 3), + "width={width}: unexpected quotient-branch coverage {quotient_values:?}" + ); + // Remainder branch: accumulator equals q and the final trit exceeds r. + let remainder_values = ((remainder + 1)..3) + .map(|trit| quotient * 3 + trit) + .filter(|&value| value < capacity) + .collect::>(); + assert!(!remainder_values.is_empty(), "width={width}"); + + for (branch, value) in quotient_values + .iter() + .map(|&value| ("quotient", value)) + .chain(remainder_values.iter().map(|&value| ("remainder", value))) + { + assert!(value > maximum); + let trits = unchecked_integer_trits(value, width); + let commitment = ternary_hash_path_commitment(&preimage, &trits); + let result = execute_script_with_inputs_strict( + script! { + { verify_ternary_hash_path_to_integer(width, commitment) } + OP_DROP OP_TRUE + }, + ternary_hash_path_witness(&preimage, &trits), + ); + assert!( + !result.success, + "{branch}: value={value}, width={width} was accepted: {result}" + ); + assert_eq!( + result.error, + Some(bitcoin_scriptexec::ExecError::Verify), + "{branch}: value={value}, width={width} was not rejected by the width check: {result}" + ); + if branch == "quotient" { + quotient_rejections += 1; + } else { + remainder_rejections += 1; + } + } + } + assert_eq!((quotient_rejections, remainder_rejections), (58, 46)); + } + #[test] fn preserves_surrounding_main_and_alt_stack_state() { let width = 6;