From d58f3093cde492b937ec674838187f660ce1f281 Mon Sep 17 00:00:00 2001 From: Breno Brito Date: Fri, 18 Sep 2026 16:45:43 -0300 Subject: [PATCH] docs(u32): describe signed compression boundary --- knowledge/catalog.json | 56 ++++++++++++++++++++++++-- knowledge/primitives/u32.md | 6 +++ src/arithmetic/u32/README.md | 9 +++++ src/arithmetic/u32/stack.rs | 76 +++++++++++++++++++++++++++++++++++- src/arithmetic/u4/mod.rs | 16 ++++---- src/arithmetic/u4/sum.rs | 1 - src/ciphers/aes/mod.rs | 11 ++++-- tests/primitive_metrics.rs | 69 ++++++++++++++++++++++++++++++++ 8 files changed, 228 insertions(+), 16 deletions(-) diff --git a/knowledge/catalog.json b/knowledge/catalog.json index 71539b84..e426ec9a 100644 --- a/knowledge/catalog.json +++ b/knowledge/catalog.json @@ -912,8 +912,9 @@ "knowledge_page": "knowledge/primitives/u32.md", "implementation": "src/arithmetic/u32/mod.rs", "documentation": "src/arithmetic/u32/README.md", - "tests": [ - "src/arithmetic/u32", + "tests": [ + "src/arithmetic/u32", + "primitive_metrics::u32_compression_metrics_are_current", "arithmetic::u32::stack::tests::test_u32_iszero", "arithmetic::u32::stack::tests::test_u32_iszero_does_not_treat_invalid_nonzero_limbs_as_zero", "arithmetic::u32::shift::tests::checked_shift_accepts_boundaries", @@ -934,7 +935,7 @@ "lookup-table" ], "security": "No independent cryptographic claim; every byte item must be canonical and in range. The canonical rrot8 adapter performs that byte boundary before rotation. The canonical rrot16 adapter performs that byte boundary before rotation.", - "stack_contract": "A u32 occupies four byte-valued items; ordering varies only through documented stack helpers. The zero predicate consumes four limbs and returns one boolean. The checked byte-equality mask consumes two words and returns one four-bit numeric mask with bit 3 for the most-significant lane and bit 0 for the least-significant lane. The checked MSB mask consumes one word and returns a four-bit numeric mask with bit 3 for the most-significant lane and bit 0 for the least-significant lane.", + "stack_contract": "A u32 occupies four byte-valued items; ordering varies only through documented stack helpers. u32_compress maps the byte word through signed two's-complement before minimal ScriptNum serialization, and u32_uncompress treats every five-byte input as the -2^31 sentinel; canonical wrappers are required for hostile wire inputs. The zero predicate consumes four limbs and returns one boolean. The checked byte-equality mask consumes two words and returns one four-bit numeric mask with bit 3 for the most-significant lane and bit 0 for the least-significant lane. The checked MSB mask consumes one word and returns a four-bit numeric mask with bit 3 for the most-significant lane and bit 0 for the least-significant lane.", "configurations": [ { "id": "add-drop", @@ -1361,6 +1362,55 @@ ], "static_non_push_opcodes": 87 }, + { + "id": "compress-unchecked", + "label": "u32_compress()", + "parameters": { + "byte_count": 4, + "mapping": "value as i32, then minimal signed ScriptNum", + "data_items": 4, + "hint_items": 0 + }, + "includes": "fragment-only: four-byte-word packing and signed ScriptNum serialization; 0x80000000 maps to -2^31 and 0xffffffff maps to -1; excludes byte-range/canonical checks, witness pushes, output check, terminal predicate, unrelated live state, and transaction context", + "script_bytes": 76, + "witness_bytes": 9, + "witness_bytes_max": 13, + "max_stack_items": 7, + "executed_opcodes": null, + "validation_weight": null, + "setup_script_bytes": 0, + "per_use_script_bytes": 76, + "metric_keys": [ + "u32_compress", + "u32_compress_witness", + "u32_compress_witness_max", + "u32_compress_stack" + ] + }, + { + "id": "uncompress-unchecked", + "label": "u32_uncompress()", + "parameters": { + "input_items": 1, + "five_byte_behavior": "any five-byte input selects -2^31", + "hint_items": 0 + }, + "includes": "fragment-only: ScriptNum expansion to four byte items; the five-byte branch is an unchecked -2^31 sentinel and inputs wider than five bytes fail during Script arithmetic; excludes canonicality checks, witness pushes, output check, terminal predicate, unrelated live state, and transaction context", + "script_bytes": 413, + "witness_bytes": 7, + "witness_bytes_max": 7, + "max_stack_items": 7, + "executed_opcodes": null, + "validation_weight": null, + "setup_script_bytes": 0, + "per_use_script_bytes": 413, + "metric_keys": [ + "u32_uncompress", + "u32_uncompress_witness", + "u32_uncompress_witness_max", + "u32_uncompress_stack" + ] + }, { "id": "compress-canonical", "label": "Canonical u32 byte-word compression", diff --git a/knowledge/primitives/u32.md b/knowledge/primitives/u32.md index 5dcc9d15..c3ae6327 100644 --- a/knowledge/primitives/u32.md +++ b/knowledge/primitives/u32.md @@ -17,6 +17,12 @@ routing for a byte-word. is 12 bytes with a 4-item peak. Little-endian bit conversion is 514 bytes with a 35-item peak. Conditional selection is 9 bytes with a 9-item peak and a 10–30-byte canonical nine-item witness. +- **Signed compression boundary:** unchecked `u32_compress()` maps the u32 + through `value as i32` before minimal ScriptNum serialization; `0xffffffff` + is `-1`, and `0x80000000` is the special five-byte `-2^31` encoding. +- **Unchecked decoder:** `u32_uncompress()` treats every five-byte input as + the `-2^31` sentinel. Use the canonical wrappers when the wire encoding is + hostile or protocol-significant. - **Narrow decoder:** canonical nonnegative compressed-u32 decoding is a smaller domain-specific alternative to the full signed decoder; it accepts only `0..=0x7fffffff` and rejects negative or aliased ScriptNums. diff --git a/src/arithmetic/u32/README.md b/src/arithmetic/u32/README.md index 36e4b8bc..08c90b42 100644 --- a/src/arithmetic/u32/README.md +++ b/src/arithmetic/u32/README.md @@ -123,6 +123,8 @@ as less-than-or-equal. | `u8_drop_xor_table()` | 128 bytes | 0 bytes | consumes 256 table items | | `u32_uncompress_canonical()` | 431 bytes | 7 bytes, 1 data item | 7 items | | `u32_uncompress_canonical_nonnegative()` | 405 bytes | 6 bytes, 1 data item | 7 items; 328 executed fragment opcodes | +| `u32_compress()` | 76 bytes | 9 bytes (13 max), 4 data items | 7 items | +| `u32_uncompress()` | 413 bytes | 7 bytes (7 max), 1 data item | 7 items | | `u32_compress_canonical()` | 130 bytes | 9 bytes (13 max), 4 data items | 7 items; 102 static non-push opcodes | | `u8_extract_hbit_checked(4)` | 73 bytes | 4 bytes, 1 data item | 5 items | | `verify_canonical_byte()` | 12 bytes | 4 bytes, 1 data item | 4 items | @@ -190,6 +192,13 @@ avoid the extra word-routing fragment. The canonical compressed-u32 row uses the maximum five-byte witness item for `-2^31`. It is a raw-encoding boundary: `u32_uncompress()` remains available for callers that intentionally accept ScriptNum aliases. + +The unchecked `u32_compress()` maps the four-byte u32 through signed +two's-complement before minimal ScriptNum serialization: `0xffffffff` becomes +`-1` (`81`), while `0x80000000` becomes `-2^31` (`00 00 00 80 80`). The +unchecked `u32_uncompress()` treats every five-byte input as that special +`-2^31` boundary. Callers needing a validated wire format must use the +canonical wrappers. The nonnegative decoder is a domain-specialized alternative: it omits signed normalization and the five-byte sentinel path, saving locking bytes while rejecting the negative half of the compressed u32 domain. diff --git a/src/arithmetic/u32/stack.rs b/src/arithmetic/u32/stack.rs index 7b1682d6..6409c26e 100644 --- a/src/arithmetic/u32/stack.rs +++ b/src/arithmetic/u32/stack.rs @@ -309,7 +309,12 @@ pub fn u32_pick(n: u32) -> Script { } } -/// Compresses the top u32 element into a single element +/// Compresses the top u32 element into a single signed ScriptNum item. +/// +/// The four MSB-first byte items are interpreted as a u32 and then mapped to +/// `value as i32`; the result uses minimal signed ScriptNum encoding. The +/// helper does not validate byte range, canonical encoding, or the unsigned +/// domain. pub fn u32_compress() -> Script { script! { OP_SWAP OP_2SWAP OP_SWAP @@ -339,6 +344,10 @@ pub fn u32_compress_canonical() -> Script { } } +/// Expands a ScriptNum of at most five bytes into four byte items. +/// +/// Any five-byte input takes the special `-2^31` branch; callers must enforce +/// the intended signed representation and canonical encoding first. pub fn u32_uncompress() -> Script { script! { OP_SIZE OP_5 OP_EQUAL @@ -481,6 +490,71 @@ mod tests { } } + #[test] + fn compress_emits_signed_scriptnum_encodings() { + for (value, expected) in [ + (0, vec![]), + (1, vec![0x01]), + (0x7f, vec![0x7f]), + (0x80, vec![0x80, 0x00]), + (0xff, vec![0xff, 0x00]), + (0x100, vec![0x00, 0x01]), + (0x7fff_ffff, vec![0xff, 0xff, 0xff, 0x7f]), + (0x8000_0000, vec![0x00, 0x00, 0x00, 0x80, 0x80]), + (u32::MAX, vec![0x81]), + ] { + let result = execute_script(script! { + { u32_push(value) } + { u32_compress() } + }); + assert!( + result.error.is_none(), + "compression failed for {value:#x}: {result}" + ); + assert_eq!( + result.final_stack.get(0), + expected, + "wrong encoding for {value:#x}" + ); + } + } + + #[test] + fn uncompress_treats_any_five_byte_input_as_the_signed_boundary() { + for raw in [ + scriptnum(-2_147_483_648), + scriptnum(2_147_483_648), + vec![1, 2, 3, 4, 5], + ] { + let result = execute_script_with_inputs_strict( + script! { + { u32_uncompress() } + { u32_push(0x8000_0000) } + { u32_equalverify() } + OP_TRUE + }, + vec![raw], + ); + assert!(result.success, "unexpected five-byte behavior: {result}"); + } + } + + #[test] + fn uncompress_rejects_scriptnums_wider_than_five_bytes() { + let result = execute_script_with_inputs_strict( + script! { + { u32_uncompress() } + { u32_drop() } + OP_TRUE + }, + vec![vec![0, 0, 0, 0, 0, 0]], + ); + assert_eq!( + result.error, + Some(bitcoin_scriptexec::ExecError::ScriptIntNumericOverflow) + ); + } + #[test] fn canonical_uncompress_rejects_raw_aliases_and_out_of_domain_words() { rejects_noncanonical(vec![0x01, 0x00]); diff --git a/src/arithmetic/u4/mod.rs b/src/arithmetic/u4/mod.rs index bab3524b..45071af0 100644 --- a/src/arithmetic/u4/mod.rs +++ b/src/arithmetic/u4/mod.rs @@ -6,19 +6,19 @@ pub mod bit_transitions; pub mod bits; pub mod centered; pub mod compare; -pub mod interleave; pub mod gray; -pub mod leading_zeros; pub mod gray_inverse; +pub mod interleave; +pub mod leading_zeros; pub mod logic; pub mod lowbit; pub mod lsb; +pub mod mirror; +pub mod mod3; pub mod mul_constant; pub mod nondecreasing; -pub mod pack; pub mod one_hot; -pub mod mirror; -pub mod mod3; +pub mod pack; pub mod parity; pub mod popcount; pub mod power_of_two; @@ -29,9 +29,9 @@ pub mod stack; pub mod stack_add; pub mod stack_logic; pub mod stack_shift; -pub mod zero; pub mod sum; +pub mod trailing_zeros; +pub mod vector_rotate; pub mod xor_reduce; +pub mod zero; pub mod zero_bitmask; -pub mod vector_rotate; -pub mod trailing_zeros; diff --git a/src/arithmetic/u4/sum.rs b/src/arithmetic/u4/sum.rs index dc4f1b50..84ad8e0e 100644 --- a/src/arithmetic/u4/sum.rs +++ b/src/arithmetic/u4/sum.rs @@ -145,7 +145,6 @@ mod tests { } } - /// Largest standalone batch before accounting for unrelated live stack state. pub const U4_EXACT_SUM_MAX_BATCH: u32 = 997; diff --git a/src/ciphers/aes/mod.rs b/src/ciphers/aes/mod.rs index 342c3c93..b9878778 100644 --- a/src/ciphers/aes/mod.rs +++ b/src/ciphers/aes/mod.rs @@ -7,7 +7,11 @@ use bitcoin::{ opcodes::{ - all::{OP_2DROP, OP_2DUP, OP_2OVER, OP_3DUP, OP_ADD, OP_DUP, OP_EQUALVERIFY, OP_FROMALTSTACK, OP_GREATERTHAN, OP_OVER, OP_PICK, OP_ROLL, OP_SUB, OP_SWAP, OP_TOALTSTACK, OP_VERIFY, OP_WITHIN}, + all::{ + OP_2DROP, OP_2DUP, OP_2OVER, OP_3DUP, OP_ADD, OP_DUP, OP_EQUALVERIFY, OP_FROMALTSTACK, + OP_GREATERTHAN, OP_OVER, OP_PICK, OP_ROLL, OP_SUB, OP_SWAP, OP_TOALTSTACK, OP_VERIFY, + OP_WITHIN, + }, Opcode, }, script::Builder, @@ -763,7 +767,9 @@ mod tests { use super::*; use crate::support::{ execution::execute_raw_script_with_inputs_strict, - execution::{execute_script, execute_script_with_inputs, execute_script_with_inputs_strict}, + execution::{ + execute_script, execute_script_with_inputs, execute_script_with_inputs_strict, + }, script::{script, ScriptCompilation}, }; @@ -783,7 +789,6 @@ mod tests { } } - fn sub_bytes_witness(bytes: [u8; 16]) -> Vec> { bytes_to_nibbles(bytes) .into_iter() diff --git a/tests/primitive_metrics.rs b/tests/primitive_metrics.rs index 247b2faa..df72528c 100644 --- a/tests/primitive_metrics.rs +++ b/tests/primitive_metrics.rs @@ -4597,6 +4597,75 @@ fn ed25519_packed_decoder_metrics_are_current() { ]); } +#[test] +fn u32_compression_metrics_are_current() { + let compress = u32::stack::u32_compress(); + let compress_witness = byte_u32_witness(0x1234_5678).to_vec(); + let compress_stack = max_stack_items_strict( + script! { + { compress.clone() } + OP_DROP + OP_TRUE + }, + compress_witness.clone(), + ); + let compress_witness_max = byte_u32_witness(0x8080_8080).to_vec(); + + let uncompress = u32::stack::u32_uncompress(); + let uncompress_witness = vec![scriptnum(-2_147_483_648)]; + let uncompress_stack = max_stack_items_strict( + script! { + { uncompress.clone() } + { u32::stack::u32_drop() } + OP_TRUE + }, + uncompress_witness.clone(), + ); + + check_readme_metrics(vec![ + Metric { + readme: "src/arithmetic/u32/README.md", + key: "u32_compress", + value: script_len(compress), + }, + Metric { + readme: "src/arithmetic/u32/README.md", + key: "u32_compress_witness", + value: witness_size(&compress_witness), + }, + Metric { + readme: "src/arithmetic/u32/README.md", + key: "u32_compress_witness_max", + value: witness_size(&compress_witness_max), + }, + Metric { + readme: "src/arithmetic/u32/README.md", + key: "u32_compress_stack", + value: compress_stack, + }, + Metric { + readme: "src/arithmetic/u32/README.md", + key: "u32_uncompress", + value: script_len(uncompress), + }, + Metric { + readme: "src/arithmetic/u32/README.md", + key: "u32_uncompress_witness", + value: witness_size(&uncompress_witness), + }, + Metric { + readme: "src/arithmetic/u32/README.md", + key: "u32_uncompress_witness_max", + value: witness_size(&uncompress_witness), + }, + Metric { + readme: "src/arithmetic/u32/README.md", + key: "u32_uncompress_stack", + value: uncompress_stack, + }, + ]); +} + #[test] fn u32_uncompress_canonical_metrics_are_current() { let fragment = u32::stack::u32_uncompress_canonical();