Skip to main content

binius_circuits/sha256/
mod.rs

1// Copyright 2026 The Binius Developers
2// Copyright 2025 Irreducible Inc.
3pub mod compress;
4
5use binius_core::word::Word;
6use binius_frontend::{CircuitBuilder, Wire};
7pub use compress::{
8	Sha256Compress2x, State, populate_message_block, ref_compress, sha256_compress,
9	sha256_compress_2x, sha256_compress_2x_seq,
10};
11
12use crate::{
13	bytes::swap_bytes_32,
14	fixed_byte_vec::ByteVec,
15	multiplexer::{multi_wire_multiplex, single_wire_multiplex},
16	util::clear_high_bits,
17};
18
19/// Computes SHA-256 hash of a fixed-length message.
20///
21/// This function creates a subcircuit that computes the SHA-256 hash of a message
22/// with a compile-time known length. Unlike `sha256_varlen`, which handles
23/// variable-length inputs, this function is optimized for fixed-length inputs where
24/// the length is known at circuit construction time.
25///
26/// # Arguments
27/// * `builder` - Circuit builder for constructing constraints
28/// * `message` - Input message as 32-bit words (4 bytes per wire) in big-endian format. Each wire
29///   must have the high 32 bits set to zero (enforced as a precondition).
30/// * `len_bytes` - The fixed length of the message in bytes (known at compile time)
31///
32/// # Returns
33/// * `[Wire; 8]` - The SHA-256 digest as 8 wires, each containing a 32-bit word in the low 32 bits
34///   (high 32 bits are zero) in big-endian order
35///
36/// # Panics
37/// * If `message.len()` does not equal exactly `len_bytes.div_ceil(4)`
38/// * If the message length in bits cannot fit in 32 bits
39///
40/// # Example
41/// ```rust,ignore
42/// use binius_frontend::crate::sha256::sha256_fixed;
43/// use binius_frontend::compiler::CircuitBuilder;
44///
45/// let mut builder = CircuitBuilder::new();
46///
47/// // Create input wires for a 32-byte message (8 32-bit words)
48/// let message: Vec<_> = (0..8).map(|_| builder.add_witness()).collect();
49///
50/// // Compute SHA-256 of the 32-byte message
51/// let digest = sha256_fixed(&builder, &message, 32);
52/// ```
53pub fn sha256_fixed(builder: &CircuitBuilder, message: &[Wire], len_bytes: usize) -> [Wire; 8] {
54	// Validate that message.len() equals exactly len_bytes.div_ceil(4)
55	assert_eq!(
56		message.len(),
57		len_bytes.div_ceil(4),
58		"message.len() ({}) must equal len_bytes.div_ceil(4) ({})",
59		message.len(),
60		len_bytes.div_ceil(4)
61	);
62
63	// Ensure message length in bits fits in 32 bits
64	assert!(
65		(len_bytes as u64)
66			.checked_mul(8)
67			.is_some_and(|bits| bits <= u32::MAX as u64),
68		"Message length in bits must fit in 32 bits"
69	);
70
71	// Calculate padding requirements
72	// SHA-256 requires: message || 0x80 || zeros || 64-bit length field
73	// The 64-bit length field goes in the last 8 bytes of a block
74	// We need at least 9 bytes for padding (1 for 0x80 + 8 for length)
75	let n_blocks = (len_bytes + 9).div_ceil(64);
76	let n_padded_words = n_blocks * 16; // 16 32-bit words per block
77
78	// Create padded message
79	let mut padded_message = Vec::with_capacity(n_padded_words);
80
81	// Add message words
82	let n_message_words = len_bytes / 4;
83	let boundary_bytes = len_bytes % 4;
84
85	// Add complete message words
86	padded_message.extend_from_slice(&message[0..n_message_words]);
87
88	// Handle partial word at boundary
89	if boundary_bytes > 0 {
90		// The last message word contains partial data
91		let last_word = message[n_message_words];
92
93		// Mask out the unused bytes and add delimiter
94		let shift_amount = (4 - boundary_bytes) * 8;
95		let mask = builder.add_constant(Word((0xFFFFFFFFu64 >> shift_amount) << shift_amount));
96		let masked = builder.band(last_word, mask);
97
98		// Add 0x80 delimiter at the right position
99		let delimiter_shift = (3 - boundary_bytes) * 8;
100		let delimiter = builder.add_constant(Word(0x80u64 << delimiter_shift));
101		let boundary_word = builder.bxor(masked, delimiter);
102
103		padded_message.push(boundary_word);
104	} else {
105		// Message ends at word boundary - delimiter goes in new word
106		padded_message.push(builder.add_constant(Word(0x80000000)));
107	}
108
109	// Fill with zeros until we reach the length field position
110	let zero = builder.add_constant(Word::ZERO);
111	padded_message.resize(n_padded_words - 2, zero);
112
113	// Add the length field (64 bits total)
114	padded_message.push(zero); // High 32 bits of length (always 0 for us)
115	let bitlen = (len_bytes as u64) * 8;
116	padded_message.push(builder.add_constant(Word(bitlen)));
117
118	// Process compression blocks two at a time.
119	// Consecutive blocks chain, so each pair runs in the two lanes of one parallel core.
120	// A pair costs ~half the AND count of two single-lane compressions.
121	// A trailing odd block has no partner and is compressed single-lane.
122	let blocks: Vec<[Wire; 16]> = padded_message
123		.chunks_exact(16)
124		.map(|block| block.try_into().unwrap())
125		.collect();
126	let n_blocks = blocks.len();
127
128	let mut state = State::iv(builder);
129	let mut block_idx = 0;
130	// The threaded state carries the pair's first compression in its high half.
131	// That half is left as it is rather than masked off, since nothing downstream reads it:
132	//
133	// - A compression never lets a carry or a rotate cross bit 32, so the halves stay apart.
134	// - The paired core takes an input state's low half only, through a left shift.
135	//
136	// So the low half of a result depends on the low halves of its inputs alone.
137	while block_idx + 1 < n_blocks {
138		// The chaining state after the pair is the second compression's output, in the low half.
139		state = sha256_compress_2x_seq(
140			&builder.subcircuit(format!("sha256_fixed_compress[{block_idx}..{}]", block_idx + 2)),
141			state,
142			[blocks[block_idx], blocks[block_idx + 1]],
143		);
144		block_idx += 2;
145	}
146	if block_idx < n_blocks {
147		// The trailing odd block has no partner, so the paired core would idle a whole lane on
148		// it. The single-lane core fills that lane from the block itself, splitting its own 64
149		// rounds across the two halves, and costs half as much.
150		//
151		// It takes a pair's leftover high halves, since it reads an input state's low half only.
152		let sub = builder.subcircuit(format!("sha256_fixed_compress[{block_idx}]"));
153		state = sha256_compress(&sub, state, blocks[block_idx]);
154	}
155
156	// The escaping digest is the one place a clean high half is required.
157	// So clear the high half once here rather than after every pair, since a caller compares all
158	// 64 bits.
159	//
160	// Why an odd block count skips it:
161	// - Such a message ends on the one-lane core.
162	// - That core empties the high half of every word it returns.
163	if n_blocks % 2 == 1 {
164		return state.0;
165	}
166	std::array::from_fn(|i| clear_high_bits(builder, state.0[i], 32))
167}
168
169/// Computes the SHA-256 hash of a variable-length message.
170///
171/// This gadget consumes a [`ByteVec`] whose actual length is runtime-determined and returns the
172/// 256-bit digest as 4 wires of 64 bits each in big-endian order, matching [`sha256_fixed`]'s
173/// output layout (produced by [`State::pack_4x64b`]).
174///
175/// Internally the gadget *computes* each 32-bit word of the SHA-256 padded message as a derived
176/// wire, classifying every word position with the flags `is_message_word`, `is_boundary_word`, and
177/// `is_length_block`. The word at the message/padding boundary mixes the trailing message bytes
178/// with the `0x80` delimiter; padding words are zero except word 15 of the length block, which
179/// holds the bit length. The compression chain is run over every possible block and the final state
180/// is selected via a multiplexer indexed by the runtime length block.
181///
182/// Unlike a checker gadget that asserts a caller-supplied digest, this function takes no such
183/// digest and performs no digest assertion: the returned digest is a single-valued function of
184/// `(data, len_bytes)`, so a free `len_bytes` can only select which message prefix is hashed, never
185/// an arbitrary digest. The caller remains responsible for constraining `len_bytes` to its intended
186/// value.
187///
188/// The input [`ByteVec`] packs bytes little-endian, whereas the compression function consumes
189/// big-endian words, so the data wires are byte-swapped up front. SHA-256's 32-bit schedule words
190/// are half the width of a `ByteVec` word, so each data word yields two consecutive schedule words.
191///
192/// # Arguments
193/// * `builder` - Circuit builder
194/// * `message` - Input message as a [`ByteVec`]. Its `len_bytes` wire holds the actual message
195///   length.
196///
197/// # Returns
198/// * `[Wire; 4]` - The SHA-256 digest as 4 wires of 64 bits each in big-endian order.
199///
200/// # Panics
201/// * If the maximum message bit length cannot be represented in the 32-bit length field.
202pub fn sha256_varlen(builder: &CircuitBuilder, message: &ByteVec) -> [Wire; 4] {
203	// ---- 1. Input validation and setup
204	//
205	// Cap the maximum bit length so the 64-bit length field's low 32 bits suffice, compute the
206	// number of compression blocks (accounting for the minimum 9 bytes of padding), and verify the
207	// actual length is within bounds.
208	let len_bytes = message.len_bytes;
209	assert!(
210		message.data.len() << Word::LOG_BITS <= u32::MAX as usize,
211		"length of message in bits must fit within 32 bits"
212	);
213
214	let max_len_bytes = message.data.len() << Word::LOG_BYTES;
215	let n_blocks = (message.data.len() + 2).div_ceil(8);
216	let n_words: usize = n_blocks << 4; // 16 words per block
217
218	let too_long = builder.icmp_ugt(len_bytes, builder.add_constant_64(max_len_bytes as u64));
219	builder.assert_false("len_check", too_long);
220
221	// `ByteVec` packs bytes little-endian; `sha256_compress` consumes big-endian 32-bit words. Each
222	// 64-bit data word carries two schedule words: `swap_bytes_32` byte-reverses within each 32-bit
223	// half, so the low half becomes the big-endian schedule word for the first four bytes and the
224	// high half the schedule word for the next four. Split each into two low-32 wires.
225	let mut message_be: Vec<Wire> = Vec::with_capacity(message.data.len() * 2);
226	for &word in &message.data {
227		let swapped = swap_bytes_32(builder, word);
228		message_be.push(clear_high_bits(builder, swapped, 32));
229		message_be.push(builder.shr(swapped, 32));
230	}
231
232	// ---- 2a. SHA-256 padding position calculation
233	let zero = builder.add_constant(Word::ZERO);
234	let w_bd = builder.shr(len_bytes, 2);
235	let len_mod_4 = builder.band(len_bytes, builder.add_constant_zx_8(3));
236	let bitlen = builder.shl(len_bytes, 3);
237
238	// end_block_index = floor((len + 8) / 64) using a 64-bit add.
239	let (sum, _carry) = builder.iadd(len_bytes, builder.add_constant_64(8));
240	let end_block_index = builder.shr(sum, 6);
241
242	// ---- Boundary word construction
243	//
244	// The 32-bit word at index `w_bd` mixes the trailing message bytes with the 0x80 delimiter.
245	// Build the four candidate words (keeping `i` leading message bytes and placing the delimiter
246	// at byte `i`) and select the one for `len_mod_4`. When `len_mod_4 == 0` the chosen candidate
247	// is `0x80000000` independent of the (possibly out-of-range) boundary message word, so the
248	// multiplexer's result is irrelevant in that case.
249	let boundary_message_word = single_wire_multiplex(builder, &message_be, w_bd);
250	let candidates: Vec<Wire> = (0..4)
251		.map(|i| {
252			let mask = builder.add_constant_64((0xFFFFFFFFu64 << ((4 - i) << 3)) & 0xFFFFFFFF);
253			let padding_byte = builder.add_constant_64(0x80000000u64 >> (i << 3));
254			let message_low = builder.band(boundary_message_word, mask);
255			builder.bxor(message_low, padding_byte)
256		})
257		.collect();
258	let boundary_word = single_wire_multiplex(builder, &candidates, len_mod_4);
259
260	// ---- Padded message words
261	//
262	// Compute each 32-bit padded word as a derived wire, classifying its position:
263	//
264	//     1. word_index <  w_bd - pure message word
265	//     2. word_index == w_bd - boundary word (message bytes + 0x80 delimiter)
266	//     3. word_index >  w_bd - pure padding, except word 15 of the length block (the bit length)
267	let padded_message: Vec<Wire> = (0..n_words)
268		.map(|word_index| {
269			let block_index = word_index >> 4;
270			let column_index = word_index & 15;
271
272			let is_message_word =
273				builder.icmp_ult(builder.add_constant_64(word_index as u64), w_bd);
274			let is_boundary_word =
275				builder.icmp_eq(builder.add_constant_64(word_index as u64), w_bd);
276			let is_length_block =
277				builder.icmp_eq(builder.add_constant_64(block_index as u64), end_block_index);
278
279			// Pure message words select the corresponding schedule word. This is only ever selected
280			// when word_index < w_bd ≤ max_len_bytes >> 2 == message_be.len(), so the index is in
281			// range; the zero fallback for word_index ≥ message_be.len() is never chosen.
282			let msg_word = if word_index < message_be.len() {
283				message_be[word_index]
284			} else {
285				zero
286			};
287
288			// Padding words are zero, except word 15 of the length block which holds the bit
289			// length. (Word 14 — the high 32 bits of the 64-bit length — stays zero, since only
290			// ≤ 32-bit bit lengths are supported.)
291			let past_word = if column_index == 15 {
292				builder.select(is_length_block, bitlen, zero)
293			} else {
294				zero
295			};
296
297			let boundary_or_past = builder.select(is_boundary_word, boundary_word, past_word);
298			builder.select(is_message_word, msg_word, boundary_or_past)
299		})
300		.collect();
301
302	// ---- Compression chain
303	//
304	// Compress two chained blocks per step through one parallel core: `sha256_compress_2x_seq` runs
305	// both in the two 32-bit lanes of a 64-bit word, for ~the AND cost of a single compression. The
306	// paired output packs the state after the first block in the high 32 bits and the state after
307	// the second block in the low 32 bits. `states[k]` therefore ends up being the state after
308	// block `k - 1`, exactly as a single-lane chain would produce, so the digest multiplexer is
309	// unchanged.
310	let mut states = Vec::with_capacity(n_blocks + 1);
311	states.push(State::iv(builder));
312	let mk_m = |block_no: usize| -> [Wire; 16] {
313		padded_message[block_no << 4..(block_no + 1) << 4]
314			.try_into()
315			.unwrap()
316	};
317	let mut block_no = 0;
318	while block_no + 1 < n_blocks {
319		let out = sha256_compress_2x_seq(
320			&builder.subcircuit(format!("compress[{block_no}..{}]", block_no + 2)),
321			states[block_no],
322			[mk_m(block_no), mk_m(block_no + 1)],
323		);
324		// Clearing the high half restores the empty half that the single-lane digest packing
325		// relies on.
326		let state_first = State::new(std::array::from_fn(|i| builder.shr(out.0[i], 32)));
327		let state_second =
328			State::new(std::array::from_fn(|i| clear_high_bits(builder, out.0[i], 32)));
329		states.push(state_first);
330		states.push(state_second);
331		block_no += 2;
332	}
333	// A trailing odd block has no partner, so compress it single-lane.
334	if block_no < n_blocks {
335		let state_out = sha256_compress(
336			&builder.subcircuit(format!("compress[{block_no}]")),
337			states[block_no],
338			mk_m(block_no),
339		);
340		states.push(state_out);
341	}
342
343	// ---- Final digest selection
344	//
345	// The digest is the state after processing the block containing the length field, packed into
346	// four 64-bit big-endian words. No caller-supplied digest is asserted — the packed selected
347	// state IS the return value.
348	let block_digests: Vec<[Wire; 4]> = states[1..].iter().map(|s| s.pack_4x64b(builder)).collect();
349	let inputs: Vec<&[Wire]> = block_digests.iter().map(|d| &d[..]).collect();
350	let final_digest_vec = multi_wire_multiplex(builder, &inputs, end_block_index);
351	final_digest_vec.try_into().unwrap()
352}
353
354#[cfg(test)]
355mod tests {
356	use std::array;
357
358	use binius_core::Word;
359	use binius_frontend::{CircuitBuilder, CircuitStat, Wire};
360	use hex_literal::hex;
361	use sha2::Digest;
362
363	use super::*;
364
365	// ---- Tests for sha256_varlen function ----
366
367	/// Builds a circuit with the given `max_len_bytes` capacity, runs `sha256_varlen` on a
368	/// `ByteVec` populated with `message_bytes`, and asserts the computed digest equals
369	/// `expected_digest`.
370	fn test_sha256_varlen_with_input(
371		message_bytes: &[u8],
372		expected_digest: [u8; 32],
373		max_len_bytes: usize,
374	) {
375		assert!(message_bytes.len() <= max_len_bytes);
376
377		let builder = CircuitBuilder::new();
378		let max_len_words = max_len_bytes.div_ceil(8);
379		let input = ByteVec::new_inout(&builder, max_len_words);
380		let expected_digest_wires: [Wire; 4] = array::from_fn(|_| builder.add_witness());
381
382		let computed_digest = sha256_varlen(&builder, &input);
383		for i in 0..4 {
384			builder.assert_eq(format!("digest[{i}]"), computed_digest[i], expected_digest_wires[i]);
385		}
386
387		let circuit = builder.build();
388		let cs = circuit.constraint_system();
389		let mut w = circuit.new_witness_filler();
390
391		input.populate_data(&mut w, message_bytes);
392		input.populate_len_bytes(&mut w, message_bytes.len());
393
394		for (i, bytes) in expected_digest.chunks(8).enumerate() {
395			let word = u64::from_be_bytes(bytes.try_into().unwrap());
396			w[expected_digest_wires[i]] = Word(word);
397		}
398
399		circuit.populate_wire_witness(&mut w).unwrap();
400		cs.verify(&w.into_value_vec()).unwrap();
401	}
402
403	#[test]
404	fn test_sha256_varlen_empty() {
405		test_sha256_varlen_with_input(
406			b"",
407			hex!("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"),
408			64,
409		);
410	}
411
412	#[test]
413	fn test_sha256_varlen_abc() {
414		test_sha256_varlen_with_input(
415			b"abc",
416			hex!("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"),
417			64,
418		);
419	}
420
421	#[test]
422	fn test_sha256_varlen_two_block_boundary() {
423		// 56 bytes forces a second block (56 + 1 delimiter + 8 length > 64).
424		test_sha256_varlen_with_input(
425			&[b'a'; 56],
426			hex!("b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a"),
427			128,
428		);
429	}
430
431	#[test]
432	fn test_sha256_varlen_various_sizes() {
433		use rand::prelude::*;
434
435		// Boundary-rich sizes around SHA-256's 64-byte block (word, block, and length-field
436		// boundaries), plus 0.
437		let sizes: Vec<usize> = vec![
438			0, 1, 3, 4, 5, 31, 32, 33, 55, 56, 63, 64, 65, 119, 120, 128, 256,
439		];
440		// Fixed capacity larger than every test message exercises the variable-length path.
441		let max_len_bytes = 320;
442
443		let mut rng = StdRng::seed_from_u64(0);
444		for size in sizes {
445			let mut message = vec![0u8; size];
446			rng.fill(&mut message[..]);
447
448			let expected = sha2::Sha256::digest(&message);
449			let expected_bytes: [u8; 32] = expected.into();
450
451			test_sha256_varlen_with_input(&message, expected_bytes, max_len_bytes);
452		}
453	}
454
455	#[test]
456	fn test_sha256_varlen_length_exceeds_max_rejection() {
457		// A `len_bytes` wire exceeding the ByteVec capacity must be rejected by the in-circuit
458		// `len_check` guard (the gadget bounds `len_bytes <= capacity` from its own data length).
459		let builder = CircuitBuilder::new();
460		let max_len_bytes = 64usize;
461		let max_len_words = max_len_bytes.div_ceil(8);
462		let input = ByteVec::new_inout(&builder, max_len_words);
463		let _ = sha256_varlen(&builder, &input);
464
465		let circuit = builder.build();
466		let mut w = circuit.new_witness_filler();
467		input.populate_data(&mut w, b"");
468		// Claim a length one byte past the capacity; the `len_check` assertion must fail.
469		w[input.len_bytes] = Word(max_len_bytes as u64 + 1);
470		assert!(circuit.populate_wire_witness(&mut w).is_err());
471	}
472
473	// Helper function for sha256_fixed tests
474	fn test_sha256_fixed_with_input(message: &[u8], expected_bytes: [u8; 32]) {
475		let b = CircuitBuilder::new();
476
477		// Pack message into 32-bit words
478		let n_words = message.len().div_ceil(4);
479		let mut message_wires = Vec::new();
480
481		for word_idx in 0..n_words {
482			let mut packed = 0u32;
483			for i in 0..4 {
484				let byte_idx = word_idx * 4 + i;
485				if byte_idx < message.len() {
486					packed |= (message[byte_idx] as u32) << (24 - i * 8);
487				}
488			}
489			message_wires.push(b.add_constant(Word(packed as u64)));
490		}
491
492		// Create expected digest wires (8 32-bit words)
493		let expected_digest_wires = array::from_fn::<_, 8, _>(|_| b.add_inout());
494
495		// Compute the digest
496		let computed_digest = sha256_fixed(&b, &message_wires, message.len());
497
498		// Assert that computed digest equals expected digest
499		for i in 0..8 {
500			b.assert_eq(format!("digest[{}]", i), computed_digest[i], expected_digest_wires[i]);
501		}
502
503		let circuit = b.build();
504		let cs = circuit.constraint_system();
505		let mut w = circuit.new_witness_filler();
506
507		// Populate the expected digest wires
508		for i in 0..8 {
509			let mut word = 0u32;
510			for j in 0..4 {
511				word |= (expected_bytes[i * 4 + j] as u32) << (24 - j * 8);
512			}
513			w[expected_digest_wires[i]] = Word(word as u64);
514		}
515
516		circuit.populate_wire_witness(&mut w).unwrap();
517		cs.verify(&w.into_value_vec()).unwrap();
518	}
519
520	#[test]
521	#[should_panic(expected = "message.len() (1) must equal len_bytes.div_ceil(4) (2)")]
522	fn test_sha256_fixed_with_insufficient_wires() {
523		use super::sha256_fixed;
524		let builder = CircuitBuilder::new();
525
526		// Create only 1 wire but claim message is 5 bytes (which needs 2 wires)
527		let message_wires: Vec<Wire> = vec![builder.add_witness()];
528
529		// This should panic because message.len() (1) != len_bytes.div_ceil(4) (2)
530		sha256_fixed(&builder, &message_wires, 5);
531	}
532
533	#[test]
534	fn test_sha256_fixed_various_sizes() {
535		use rand::prelude::*;
536
537		// Test various message sizes to ensure padding works correctly
538		let sizes = vec![
539			0,   // empty
540			1,   // single byte
541			3,   // "abc" test vector
542			4,   // exactly one word
543			5,   // just over word boundary
544			31,  // just under half block
545			32,  // exactly half block
546			33,  // just over half block
547			55,  // max single block
548			56,  // forces two blocks
549			63,  // one byte from block boundary
550			64,  // exactly one block
551			65,  // just over one block
552			119, // max two blocks
553			120, // forces three blocks
554			128, // exactly two blocks
555			256, // exactly four blocks
556		];
557
558		let mut rng = StdRng::seed_from_u64(0);
559
560		for size in sizes {
561			// Generate random payload
562			let mut message = vec![0u8; size];
563			rng.fill(&mut message[..]);
564
565			// Compute expected hash using sha2 crate
566			let expected = sha2::Sha256::digest(&message);
567			let expected_bytes: [u8; 32] = expected.into();
568
569			// Test with our circuit
570			test_sha256_fixed_with_input(&message, expected_bytes);
571		}
572	}
573
574	#[test]
575	fn every_block_costs_the_same() {
576		// AND constraints one compression spends, as `compress`'s own bound pins it.
577		const AND_PER_BLOCK: usize = 364;
578
579		// Lengths chosen to end on a word, so no boundary mask joins the count.
580		for (len_bytes, n_blocks) in [(32, 1), (64, 2), (128, 3), (192, 4), (256, 5)] {
581			let b = CircuitBuilder::new();
582			let message: Vec<Wire> = (0..len_bytes / 4).map(|_| b.add_inout()).collect();
583
584			// Bind the digest, or dead code elimination drops the last compression's tail.
585			for wire in sha256_fixed(&b, &message, len_bytes) {
586				let public = b.add_inout();
587				b.assert_eq("digest", wire, public);
588			}
589
590			// The paired core carries two blocks and the single-lane core carries one.
591			// Both spend the two 32-bit halves of every gate, so an odd count costs no more
592			// per block than an even one.
593			let stat = CircuitStat::collect(&b.build());
594			assert_eq!(stat.n_and_constraints, n_blocks * AND_PER_BLOCK, "{len_bytes} bytes");
595		}
596	}
597
598	/// Hashes `message` with [`Sha256Compress2x`] as a chip, and checks the digest and the system.
599	///
600	/// The digest wires are public and filled with the reference digest, so a disagreement fails
601	/// to populate. What the chip adds is checked after: the paired compressions have to be
602	/// served by an instance that recomputes the same words.
603	fn check_fixed_with_compress_chip(message: &[u8]) {
604		let b = CircuitBuilder::new();
605		b.register_chip(Sha256Compress2x, &[]);
606
607		let n_words = message.len().div_ceil(4);
608		let message_wires: Vec<Wire> = (0..n_words).map(|_| b.add_witness()).collect();
609		let computed_digest = sha256_fixed(&b, &message_wires, message.len());
610		let digest_out: [Wire; 8] = array::from_fn(|_| b.add_inout());
611		for i in 0..8 {
612			b.assert_eq(format!("digest[{i}]"), computed_digest[i], digest_out[i]);
613		}
614
615		let circuit = b.build_m4();
616		circuit.validate().unwrap();
617		let cs = circuit.to_constraint_system();
618		cs.validate().unwrap();
619
620		let expected: [u8; 32] = sha2::Sha256::digest(message).into();
621
622		let witness = circuit
623			.generate_witness(|w| {
624				for (word_idx, wire) in message_wires.iter().enumerate() {
625					let mut packed = 0u32;
626					for i in 0..4 {
627						let byte_idx = word_idx * 4 + i;
628						if byte_idx < message.len() {
629							packed |= (message[byte_idx] as u32) << (24 - i * 8);
630						}
631					}
632					w[*wire] = Word(packed as u64);
633				}
634				for i in 0..8 {
635					let mut word = 0u32;
636					for j in 0..4 {
637						word |= (expected[i * 4 + j] as u32) << (24 - j * 8);
638					}
639					w[digest_out[i]] = Word(word as u64);
640				}
641			})
642			.unwrap_or_else(|e| {
643				panic!("sha256_fixed failed for len_bytes={}: {e:?}", message.len())
644			});
645
646		witness.verify(&cs).unwrap();
647	}
648
649	// The layers between `sha256_fixed` and `sha256_compress_2x` are untouched by the chip: block
650	// pairs, and the trailing odd block riding the paired core with a dead lane, land as calls
651	// because the builder holds the chip, not because anything in between was told. Lengths cover
652	// one pair, a pair plus a trailing block, two pairs, and two pairs plus a trailing block.
653	#[test]
654	fn a_registered_chip_serves_every_paired_compression() {
655		for &len in &[64usize, 128, 192, 300] {
656			let message: Vec<u8> = (0..len).map(|i| (i * 37 + 1) as u8).collect();
657			check_fixed_with_compress_chip(&message);
658		}
659	}
660
661	// A single-block message compresses single-lane only and never reaches the paired gadget, so
662	// its chip goes uncalled and the system it leaves is not one that can be populated.
663	#[test]
664	fn a_chip_no_paired_compression_reaches_leaves_an_uncalled_chip() {
665		let b = CircuitBuilder::new();
666		b.register_chip(Sha256Compress2x, &[]);
667		sha256_fixed(&b, &[b.add_witness()], 4);
668
669		let error = b.build_m4().validate().unwrap_err();
670		assert!(matches!(error, binius_frontend::CircuitM4Error::NeverCalled { .. }), "{error:?}");
671	}
672}