Skip to main content

binius_circuits/sha256/
compress.rs

1// Copyright 2025 Irreducible Inc.
2use std::{array, iter};
3
4use binius_core::word::Word;
5use binius_frontend::{ChipGadget, CircuitBuilder, Hint, Wire, WitnessFiller};
6
7use crate::util::{clear_high_bits, pack_u32_words};
8
9const IV: [u32; 8] = [
10	0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
11];
12
13const K: [u32; 64] = [
14	0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
15	0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
16	0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
17	0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
18	0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
19	0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
20	0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
21	0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
22];
23
24/// The internal state of SHA-256.
25///
26/// The state size is 256 bits. For efficiency reasons it's packed in 8 x 32-bit words, and not
27/// 4 x 64-bit words.
28///
29/// The elements are referred to as a–h or H0–H7.
30#[derive(Clone, Copy)]
31pub struct State(pub [Wire; 8]);
32
33impl State {
34	pub const fn new(wires: [Wire; 8]) -> Self {
35		State(wires)
36	}
37
38	pub fn public(builder: &CircuitBuilder) -> Self {
39		State(std::array::from_fn(|_| builder.add_inout()))
40	}
41
42	pub fn private(builder: &CircuitBuilder) -> Self {
43		State(std::array::from_fn(|_| builder.add_witness()))
44	}
45
46	pub fn iv(builder: &CircuitBuilder) -> Self {
47		State(std::array::from_fn(|i| builder.add_constant(Word(IV[i] as u64))))
48	}
49
50	/// Packs the state into 4 x 64-bit big-endian words.
51	///
52	/// The lower-numbered word of each pair is the more significant, so it takes the high half.
53	///
54	/// # Preconditions
55	///
56	/// - Every state word holds its value in the low 32 bits, with the high 32 empty.
57	///   - The word lifted into the high half has its own high bits discarded by the shift.
58	///   - The word entering the low half is passed through unmasked.
59	///
60	/// Masking it here would commit one redundant intermediate per word.
61	/// Every caller already clears before packing.
62	pub fn pack_4x64b(&self, builder: &CircuitBuilder) -> [Wire; 4] {
63		array::from_fn(|j| builder.bxor(self.0[2 * j + 1], builder.shl(self.0[2 * j], 32)))
64	}
65}
66
67/// Rounds [`sha256_compress`] runs in its high lane, the first half of the 64.
68///
69/// The low lane runs the other half, so both lanes carry 32 rounds and neither idles.
70const HIGH_LANE_ROUNDS: usize = 32;
71
72/// Gap in schedule index between the two lanes of [`sha256_compress`]'s message schedule.
73///
74/// The 48 derived words split evenly in two, so 24 packed passes derive all of them:
75///
76/// ```text
77///     low  lane derives w[16..40]
78///     high lane derives w[40..64]
79/// ```
80const SCHEDULE_LANE_GAP: usize = 24;
81
82/// Packed schedule words a compression builds, pairing `w[0..40]` with `w[24..64]`.
83const PACKED_SCHEDULE_WORDS: usize = 64 - SCHEDULE_LANE_GAP;
84
85/// Distance from a round index to the packed schedule word holding that round's low-lane word.
86///
87/// The rounds want `w[j]` beside `w[j + 32]`, while the schedule pairs `w[i]` with `w[i + 24]`.
88/// So the second of the pair sits 32 - 24 = 8 packed words further along.
89const ROUND_WORD_LAG: usize = HIGH_LANE_ROUNDS - SCHEDULE_LANE_GAP;
90
91/// SHA-256 compression function.
92///
93/// Runs the message schedule and 64 rounds over `state_in` for one 512-bit block.
94/// The round output is then added back into `state_in`, giving the updated state.
95///
96/// A gate costs one constraint whatever its operand width.
97/// So one compression is evaluated as the two 32-bit lanes of a single 64-bit core.
98///
99/// Each phase is cut in half, and the halves run side by side instead of one after the other:
100///
101/// ```text
102///     schedule, high lane [32:64]:  w[24] .. w[39]  ->  w[40] .. w[63]
103///     schedule, low  lane [0:32] :  w[0]  .. w[15]  ->  w[16] .. w[39]
104///
105///     rounds,   high lane [32:64]:  round 0  -> ... -> round 31
106///     rounds,   low  lane [0:32] :  round 32 -> ... -> round 63
107/// ```
108///
109/// Each half now needs what the other half produces, which no ordering of gates can supply.
110///
111/// - The schedule's high lane opens at `w[24]`, which the low lane only reaches at the end.
112/// - The rounds' low lane opens at the state after round 31, which the high lane ends on.
113///
114/// A hint breaks both dependencies at once by computing those two seeds off-circuit.
115///
116/// Every hinted word is then constrained against what the circuit itself computed.
117/// So the hint can be wrong, but a wrong one has no satisfying witness.
118///
119/// 24 packed passes and 32 packed rounds replace 48 and 64 single-lane ones.
120/// That halves the AND constraints a compression spends.
121///
122/// # Arguments
123///
124/// - `state_in`: the 8-word input state, value in the low 32 bits of each wire.
125/// - `m`: 16 big-endian message words for this block, value in the low 32 bits of each wire.
126///
127/// # Preconditions
128///
129/// - Every input wire holds a valid 32-bit value in its low 32 bits.
130/// - High halves need not be empty.
131///   - A message word's high half is masked off.
132///   - A state word's is discarded by the shift that lifts it into the high lane.
133///
134/// # Returns
135///
136/// The updated 8-word state, value in the low 32 bits of each wire and the high 32 bits zero.
137pub fn sha256_compress(builder: &CircuitBuilder, state_in: State, m: [Wire; 16]) -> State {
138	// One hint seeds both splits.
139	//
140	//     seeds  0..16 :  the schedule's two opening lanes
141	//     seeds 16..24 :  the rounds' two opening states
142	//
143	// Every one is re-derived and constrained below, so the hint is untrusted.
144	let mut hint_inputs = Vec::with_capacity(24);
145	hint_inputs.extend_from_slice(&state_in.0);
146	hint_inputs.extend_from_slice(&m);
147	let seeds = builder.call_hint(Sha256SplitHint, &[], &hint_inputs);
148
149	// Packed word `t` carries w_t in the low lane and w_{t+24} in the high lane.
150	//
151	//     the recurrence reads:  t-16, t-15, t-7, t-2
152	//     shift all four by 24:  the result shifts by 24 too
153	//
154	// So one pass advances both lanes at once.
155	let mut w: Vec<Wire> = seeds[..16].to_vec();
156	for t in 16..PACKED_SCHEDULE_WORDS {
157		let s0 = small_sigma_0(builder, w[t - 15]);
158		let s1 = small_sigma_1(builder, w[t - 2]);
159		let p = builder.iadd_32(w[t - 16], s0);
160		let q = builder.iadd_32(p, w[t - 7]);
161		w.push(builder.iadd_32(q, s1));
162	}
163
164	// Bind the 16 hinted schedule seeds, one 64-bit equality each.
165	// A single equality pins both lanes, since the two halves never overlap.
166	//
167	//     hinted word:  [ high lane = w[i + 24] | low lane = w[i] ]
168	//                          must equal              must equal
169	//                     w[i + 24] << 32       ^      m[i] masked
170	//
171	// The low lane is pinned to the caller's own message word.
172	//
173	// The high lane is pinned to the word the low lane provably derived from it.
174	// Low lanes depend on the message alone, so that derivation is not circular.
175	for (i, seed) in seeds[..16].iter().enumerate() {
176		let low = clear_high_bits(builder, m[i], 32);
177		let high = builder.shl(w[i + SCHEDULE_LANE_GAP], 32);
178		builder.assert_eq("sha256_compress.schedule_seed", *seed, builder.bxor(low, high));
179	}
180
181	// One constant per packed round: the high lane's K[j] over the low lane's K[32 + j].
182	let k: [Wire; HIGH_LANE_ROUNDS] = array::from_fn(|j| {
183		let packed = K[HIGH_LANE_ROUNDS + j] as u64 | ((K[j] as u64) << 32);
184		builder.add_constant(Word(packed))
185	});
186
187	let merged: [Wire; 8] = array::from_fn(|i| seeds[16 + i]);
188	let mut state = State(merged);
189	for j in 0..HIGH_LANE_ROUNDS {
190		// Repack the round's schedule word from the schedule's pairing into the rounds':
191		//
192		//     high lane <- w[j]      = the low  lane of packed word j
193		//     low  lane <- w[j + 32] = the high lane of packed word j + 8
194		//
195		// Each side is one shift of a committed word, so neither needs masking.
196		let w_t = builder.bxor(builder.shl(w[j], 32), builder.shr(w[j + ROUND_WORD_LAG], 32));
197		state = round(builder, k[j], w_t, state);
198	}
199
200	// The high lane has now produced the state the low lane started from.
201	// Bind the hint to it, one 64-bit equality per state word.
202	//
203	//     hinted word:  [ high lane = input state | low lane = state after round 31 ]
204	//                            must equal                    must equal
205	//                     input state << 32          ^        high lane >> 32
206	//
207	// The high lane is pinned to the caller's own state.
208	//
209	// The low lane is pinned to the state the high lane provably reached from it.
210	// Together those leave the hint no freedom.
211	for (hinted, (input, split)) in iter::zip(merged, iter::zip(state_in.0, state.0)) {
212		let expected = builder.bxor(builder.shl(input, 32), builder.shr(split, 32));
213		builder.assert_eq("sha256_compress.round_state", hinted, expected);
214	}
215
216	// Feed-forward, two output words per gate.
217	//
218	// The rounds have consumed the split, so both lanes are free again.
219	// Pairing the words refills them, and 8 additions become 4:
220	//
221	//     low lane  <- H[2j]
222	//     high lane <- H[2j + 1]
223	//
224	// Neither operand arrives zero-extended, so the packing masks the one entering the low lane:
225	//
226	// - A state word's high half holds the round-31 state the split already spent.
227	// - A caller's high half is not required to be empty.
228	let sums: [Wire; 4] = array::from_fn(|j| {
229		builder.iadd_32(
230			pack_u32_words(builder, state_in.0[2 * j], state_in.0[2 * j + 1]),
231			pack_u32_words(builder, state.0[2 * j], state.0[2 * j + 1]),
232		)
233	});
234
235	// A caller reads one compression's output, in the low 32 bits with the high 32 zero.
236	State(array::from_fn(|i| {
237		let sum = sums[i / 2];
238		if i % 2 == 0 {
239			clear_high_bits(builder, sum, 32)
240		} else {
241			builder.shr(sum, 32)
242		}
243	}))
244}
245
246/// Seeds both of [`sha256_compress`]'s lane splits in one call.
247///
248/// Runs the message schedule and the first half of the rounds off-circuit.
249/// Each result is packed so it can be fed straight in as a two-lane opening value.
250///
251/// Every output is re-derived in-circuit and constrained, so the hint only needs to be honest.
252///
253/// Input layout, 24 words with the value in the low 32 bits of each:
254///
255/// - words `0..8`: the input state.
256/// - words `8..24`: the message block.
257///
258/// Output layout, 24 packed words:
259///
260/// - words `0..16`: `w[i]` low, `w[i + 24]` high.
261/// - words `16..24`: the state after round 31 low, the input state high.
262struct Sha256SplitHint;
263
264impl Hint for Sha256SplitHint {
265	const NAME: &'static str = "binius.sha256_compress_split";
266
267	fn shape(&self, _dimensions: &[usize]) -> (usize, usize) {
268		(24, 24)
269	}
270
271	fn execute(&self, _dimensions: &[usize], inputs: &[Word], outputs: &mut [Word]) {
272		// The gadget masks every input down to 32 bits, so the hint reads the same low halves.
273		let state_in: [u32; 8] = array::from_fn(|i| inputs[i].as_u64() as u32);
274		let m: [u32; 16] = array::from_fn(|i| inputs[8 + i].as_u64() as u32);
275
276		// Schedule seed `i` pairs the word the caller supplied with the one 24 places later.
277		let w = ref_schedule(m);
278		for (i, slot) in outputs[..16].iter_mut().enumerate() {
279			*slot = Word(w[i] as u64 | ((w[i + SCHEDULE_LANE_GAP] as u64) << 32));
280		}
281
282		// Round seed `i` pairs the two lanes' starting states: round 32's below, round 0's above.
283		let split = ref_rounds(state_in, &w, HIGH_LANE_ROUNDS);
284		for (i, slot) in outputs[16..].iter_mut().enumerate() {
285			*slot = Word(split[i] as u64 | ((state_in[i] as u64) << 32));
286		}
287	}
288}
289
290/// SHA-256 compression function running two independent compressions in parallel.
291///
292/// Each 64-bit input wire packs two 32-bit lanes: bits `[0:32]` hold the lane-0 word, bits
293/// `[32:64]` hold the lane-1 word. SHA-256's mixing is built entirely from the parallel-halves
294/// gates ([`iadd_32`](CircuitBuilder::iadd_32), [`rotr32`](CircuitBuilder::rotr32),
295/// [`srl32`](CircuitBuilder::srl32)) plus lane-agnostic `bxor`/`band`, so the schedule and all 64
296/// rounds run both compressions at the gate cost of a single one. The only lane-specific detail is
297/// the round constants, which are replicated into both halves here.
298///
299/// # Arguments
300///
301/// - `state_in`: the 8-word input state, each wire packing both lanes' words.
302/// - `m`: 16 message words for this block, each wire packing both lanes' words.
303///
304/// It is a PRECONDITION that each 32-bit lane be a valid 32-bit value (no spillover across the
305/// lane boundary), i.e. the input words fit in their respective halves. It is the caller's
306/// responsibility to ensure this; otherwise the gadget's behavior is undefined / insecure.
307///
308/// # Returns
309///
310/// The updated 8-word state, with each wire packing both lanes' results.
311///
312/// # Chips
313///
314/// This is a [`ChipGadget`]. A circuit that calls
315/// [`register_chip`](CircuitBuilder::register_chip) with [`Sha256Compress2x`] before building
316/// turns every paired compression under it into a chip call, including the ones
317/// [`sha256_compress_2x_seq`] and the fixed and variable length hashers reach.
318pub fn sha256_compress_2x(builder: &CircuitBuilder, state_in: State, m: [Wire; 16]) -> State {
319	let inputs = state_in.0.into_iter().chain(m).collect::<Vec<_>>();
320
321	let outputs = builder.build_gadget(Sha256Compress2x, &[], &inputs);
322	State(array::from_fn(|i| outputs[i]))
323}
324
325/// [`sha256_compress_2x`] as a gadget, so that a circuit can make it a chip.
326///
327/// Its interface is the flat 24 input words `state[0..8]`, `m[0..16]`, and the 8 output state
328/// words, all packing two lanes as [`sha256_compress_2x`] documents.
329pub struct Sha256Compress2x;
330
331impl Hint for Sha256Compress2x {
332	const NAME: &'static str = "binius.sha256_compress_2x";
333
334	fn shape(&self, _dimensions: &[usize]) -> (usize, usize) {
335		(24, 8)
336	}
337
338	fn execute(&self, _dimensions: &[usize], inputs: &[Word], outputs: &mut [Word]) {
339		// Each lane is a 32-bit half of every input word, and the halves never interact: the
340		// core's adds, rotates and shifts are 32-bit and its ch/maj are bitwise. So a lane is
341		// the reference compression of its own halves.
342		let compress_lane = |i: usize| {
343			let lane = |word: Word| (word.as_u64() >> (32 * i)) as u32;
344			let state: [u32; 8] = array::from_fn(|j| lane(inputs[j]));
345			let m: [u32; 16] = array::from_fn(|j| lane(inputs[8 + j]));
346			ref_compress(state, m)
347		};
348
349		let (lane_0, lane_1) = (compress_lane(0), compress_lane(1));
350		for (slot, (low, high)) in iter::zip(outputs, iter::zip(lane_0, lane_1)) {
351			*slot = Word(low as u64 | ((high as u64) << 32));
352		}
353	}
354}
355
356impl ChipGadget for Sha256Compress2x {
357	fn build(&self, builder: &CircuitBuilder, _dimensions: &[usize], inputs: &[Wire]) -> Vec<Wire> {
358		let state_in = State(array::from_fn(|i| inputs[i]));
359		let m: [Wire; 16] = array::from_fn(|i| inputs[8 + i]);
360		compress_2x_gates(builder, state_in, m).0.to_vec()
361	}
362}
363
364/// [`sha256_compress_2x`] in gates, whatever the building circuit does with the gadget.
365fn compress_2x_gates(builder: &CircuitBuilder, state_in: State, m: [Wire; 16]) -> State {
366	// Round constants replicated into both 32-bit halves, so each lane adds the same K[t].
367	let k: [Wire; 64] = std::array::from_fn(|t| {
368		let kt = K[t] as u64;
369		builder.add_constant(Word(kt | (kt << 32)))
370	});
371	compress_inner(builder, state_in, m, &k)
372}
373
374/// Two *sequential* SHA-256 block compressions evaluated in one parallel core.
375///
376/// The second block's compression takes the first block's output state as its input state.
377/// Both run as the two 32-bit lanes of a single parallel compression:
378///
379/// ```text
380///     high lane [32:64]:  S1 = compress(input state, first block)
381///     low  lane [0:32] :  S2 = compress(S1,          second block)
382/// ```
383///
384/// So two chained blocks cost one compression instead of two.
385///
386/// The two lanes run concurrently, yet the low lane needs the high lane's *output* as its input.
387/// A hint breaks this dependency by precomputing `S1` off-circuit.
388/// The hinted `S1` seeds the low lane's input and is constrained two ways, so it cannot lie:
389///
390/// - its high half must equal the real input state (the first compression's input).
391/// - its low half must equal the first compression's in-circuit output.
392///
393/// # Arguments
394///
395/// - `state_in`: 8-word input state for the first compression, value in the low 32 bits of each.
396/// - `blocks`: two 16-word message blocks; `blocks[0]` feeds the first, `blocks[1]` the second.
397///
398/// # Preconditions
399///
400/// - Every input wire holds a valid 32-bit value in its low 32 bits.
401/// - High halves need not be empty.
402///   - A block word's high half is masked off.
403///   - A state word's is discarded by the shift that lifts it into the high lane.
404///
405/// # Returns
406///
407/// 8 wires, each packing both output states:
408///
409/// - low 32 bits: the second compression's output.
410/// - high 32 bits: the first compression's output.
411pub fn sha256_compress_2x_seq(
412	builder: &CircuitBuilder,
413	state_in: State,
414	blocks: [[Wire; 16]; 2],
415) -> State {
416	// The hint returns the merged input state directly, packed per word as:
417	// - low 32 bits : first compression's output  = second compression's input (low lane)
418	// - high 32 bits: first compression's input state word (high lane)
419	//
420	// Both halves are re-derived and constrained below, so the hint is untrusted.
421	let mut hint_inputs = Vec::with_capacity(24);
422	hint_inputs.extend_from_slice(&state_in.0);
423	hint_inputs.extend_from_slice(&blocks[0]);
424	let merged_vec = builder.call_hint(Sha256CompressHint, &[], &hint_inputs);
425	let merged: [Wire; 8] = array::from_fn(|i| merged_vec[i]);
426
427	// Merged block, one wire per block word:
428	//
429	//     low lane  <- the second block's word
430	//     high lane <- the first block's word
431	let merged_block: [Wire; 16] =
432		array::from_fn(|i| pack_u32_words(builder, blocks[1][i], blocks[0][i]));
433
434	let out = sha256_compress_2x(builder, State::new(merged), merged_block);
435
436	// Bind the hinted state, one 64-bit equality per word.
437	// A single equality pins both halves, since they never overlap.
438	//
439	//     bits 32..64 <- the first compression's declared input, shifted up
440	//     bits  0..32 <- the first compression's in-circuit output, shifted down
441	//
442	//     hinted word:  [ high lane = input state | low lane = S1 output ]
443	//                          must equal                must equal
444	//                     input state << 32       ^     result >> 32
445	//
446	// Consequences, which together leave the hint no freedom:
447	// - The high lane provably compresses the caller's own state.
448	// - The low lane provably chains from what the first compression really produced.
449	//
450	// Each side is one shift of a committed word, so no masking is needed:
451	// - Shifting up discards the input's own high bits.
452	// - Shifting down discards the result's low bits.
453	for (m, (s, o)) in iter::zip(merged, iter::zip(state_in.0, out.0)) {
454		let expected = builder.bxor(builder.shl(s, 32), builder.shr(o, 32));
455		builder.assert_eq("sha256_compress_2x_seq.merged_state", m, expected);
456	}
457
458	out
459}
460
461/// Precomputes the merged input state for the sequential two-lane compression.
462///
463/// Runs the first compression off-circuit and packs each output word to seed both lanes at once:
464///
465/// - low 32 bits: the first compression's output = the second compression's input.
466/// - high 32 bits: the first compression's input state word.
467///
468/// Both halves are re-derived and constrained in-circuit, so the hint only needs to be honest.
469///
470/// Input layout, 24 words with the value in the low 32 bits of each:
471///
472/// - words `0..8`: the input state.
473/// - words `8..24`: the first message block.
474struct Sha256CompressHint;
475
476impl Hint for Sha256CompressHint {
477	const NAME: &'static str = "binius.sha256_compress";
478
479	fn shape(&self, _dimensions: &[usize]) -> (usize, usize) {
480		(24, 8)
481	}
482
483	fn execute(&self, _dimensions: &[usize], inputs: &[Word], outputs: &mut [Word]) {
484		let state_in: [u32; 8] = array::from_fn(|i| inputs[i].as_u64() as u32);
485		let block: [u32; 16] = array::from_fn(|i| inputs[8 + i].as_u64() as u32);
486
487		let out = ref_compress(state_in, block);
488		for (i, slot) in outputs.iter_mut().enumerate() {
489			*slot = Word(out[i] as u64 | ((state_in[i] as u64) << 32));
490		}
491	}
492}
493
494/// Pure-Rust SHA-256 compression of a single 512-bit block.
495///
496/// Matches the in-circuit compression exactly.
497/// Used for prover-side witness generation and as the test reference.
498///
499/// # Arguments
500///
501/// - `state_in`: the 8-word input state, one 32-bit word per entry.
502/// - `m`: the 16-word message block, one 32-bit word per entry.
503///
504/// # Returns
505///
506/// The updated 8-word state.
507pub fn ref_compress(state_in: [u32; 8], m: [u32; 16]) -> [u32; 8] {
508	let w = ref_schedule(m);
509	let out = ref_rounds(state_in, &w, 64);
510	array::from_fn(|i| state_in[i].wrapping_add(out[i]))
511}
512
513/// Expands a block's 16 message words into the full 64-word SHA-256 message schedule.
514fn ref_schedule(m: [u32; 16]) -> [u32; 64] {
515	let mut w = [0u32; 64];
516	w[..16].copy_from_slice(&m);
517	for t in 16..64 {
518		let s0 = w[t - 15].rotate_right(7) ^ w[t - 15].rotate_right(18) ^ (w[t - 15] >> 3);
519		let s1 = w[t - 2].rotate_right(17) ^ w[t - 2].rotate_right(19) ^ (w[t - 2] >> 10);
520		w[t] = w[t - 16]
521			.wrapping_add(s0)
522			.wrapping_add(w[t - 7])
523			.wrapping_add(s1);
524	}
525	w
526}
527
528/// Runs the first `rounds` SHA-256 rounds over `state_in`, without the feed-forward add.
529///
530/// # Returns
531///
532/// The working variables a–h once round `rounds - 1` has run.
533fn ref_rounds(state_in: [u32; 8], w: &[u32; 64], rounds: usize) -> [u32; 8] {
534	let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = state_in;
535	for t in 0..rounds {
536		let big_s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
537		let ch = (e & f) ^ ((!e) & g);
538		let t1 = h
539			.wrapping_add(big_s1)
540			.wrapping_add(ch)
541			.wrapping_add(K[t])
542			.wrapping_add(w[t]);
543		let big_s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
544		let maj = (a & b) ^ (a & c) ^ (b & c);
545		let t2 = big_s0.wrapping_add(maj);
546		h = g;
547		g = f;
548		f = e;
549		e = d.wrapping_add(t1);
550		d = c;
551		c = b;
552		b = a;
553		a = t1.wrapping_add(t2);
554	}
555
556	[a, b, c, d, e, f, g, h]
557}
558
559/// Core of [`sha256_compress_2x`]: runs the message schedule and 64 rounds over `state_in`, then
560/// adds the round output back into `state_in`.
561///
562/// Every operation is a parallel-halves or lane-agnostic gate, and `k` replicates each round
563/// constant into both halves, so one pass advances two independent compressions.
564fn compress_inner(
565	builder: &CircuitBuilder,
566	state_in: State,
567	m: [Wire; 16],
568	k: &[Wire; 64],
569) -> State {
570	// ---- message-schedule ----
571	// W[0..15] = block_words
572	// for t = 16 .. 63:
573	//     s0   = σ0(W[t-15])
574	//     s1   = σ1(W[t-2])
575	//     (p, _)  = Add32(W[t-16], s0)
576	//     (q, _)  = Add32(p, W[t-7])
577	//     (W[t],_) = Add32(q, s1)
578
579	let mut w: Vec<Wire> = Vec::with_capacity(64);
580	// W[0..15] = block_words
581	w.extend_from_slice(&m);
582
583	// W[16..63] computed from previous W values
584	for t in 16..64 {
585		let s0 = small_sigma_0(builder, w[t - 15]);
586		let s1 = small_sigma_1(builder, w[t - 2]);
587		let p = builder.iadd_32(w[t - 16], s0);
588		let q = builder.iadd_32(p, w[t - 7]);
589		w.push(builder.iadd_32(q, s1));
590	}
591
592	let w: &[Wire; 64] = (&*w).try_into().unwrap();
593	let mut state = state_in;
594	for t in 0..64 {
595		state = round(builder, k[t], w[t], state);
596	}
597
598	// Add the compressed chunk to the current hash value
599	State([
600		builder.iadd_32(state_in.0[0], state.0[0]),
601		builder.iadd_32(state_in.0[1], state.0[1]),
602		builder.iadd_32(state_in.0[2], state.0[2]),
603		builder.iadd_32(state_in.0[3], state.0[3]),
604		builder.iadd_32(state_in.0[4], state.0[4]),
605		builder.iadd_32(state_in.0[5], state.0[5]),
606		builder.iadd_32(state_in.0[6], state.0[6]),
607		builder.iadd_32(state_in.0[7], state.0[7]),
608	])
609}
610
611/// Populates the 16 message-block wires of a [`sha256_compress`] block from its 64 message bytes.
612///
613/// The bytes are packed big-endian into 16 32-bit words, one per wire, with the high 32 bits left
614/// zero — matching the precondition on `m` documented in [`sha256_compress`].
615pub fn populate_message_block(w: &mut WitnessFiller<'_>, m: &[Wire; 16], bytes: [u8; 64]) {
616	for (wire, chunk) in m.iter().zip(bytes.chunks_exact(4)) {
617		let word = u32::from_be_bytes(chunk.try_into().unwrap());
618		w[*wire] = Word(word as u64);
619	}
620}
621
622fn round(builder: &CircuitBuilder, k_t: Wire, w_t: Wire, state: State) -> State {
623	let State([a, b, c, d, e, f, g, h]) = state;
624
625	let big_sigma_e = big_sigma_1(builder, e);
626	let ch_efg = ch(builder, e, f, g);
627	let t1a = builder.iadd_32(h, big_sigma_e);
628	let t1b = builder.iadd_32(t1a, ch_efg);
629	let t1c = builder.iadd_32(t1b, k_t);
630	let t1 = builder.iadd_32(t1c, w_t);
631
632	let big_sigma_a = big_sigma_0(builder, a);
633	let maj_abc = maj(builder, a, b, c);
634	let t2 = builder.iadd_32(big_sigma_a, maj_abc);
635
636	let h = g;
637	let g = f;
638	let f = e;
639	let e = builder.iadd_32(d, t1);
640	let d = c;
641	let c = b;
642	let b = a;
643	let a = builder.iadd_32(t1, t2);
644
645	State([a, b, c, d, e, f, g, h])
646}
647
648/// Ch(x, y, z) = (x AND y) XOR (NOT y AND z)
649///             = z XOR (x AND (y XOR z))
650fn ch(builder: &CircuitBuilder, x: Wire, y: Wire, z: Wire) -> Wire {
651	builder.bxor(z, builder.band(x, builder.bxor(y, z)))
652}
653
654/// Maj(x, y, z) = (x AND y) XOR (x AND z) XOR (y AND z)
655///              = (x XOR z) AND (y XOR z) XOR z.
656fn maj(builder: &CircuitBuilder, x: Wire, y: Wire, z: Wire) -> Wire {
657	builder.bxor(builder.band(builder.bxor(x, z), builder.bxor(y, z)), z)
658}
659
660/// Σ0(a)       = XOR( XOR( ROTR(x,  2), ROTR(x, 13) ), ROTR(x, 22) )
661fn big_sigma_0(b: &CircuitBuilder, x: Wire) -> Wire {
662	let r1 = b.rotr32(x, 2);
663	let r2 = b.rotr32(x, 13);
664	let r3 = b.rotr32(x, 22);
665	let x1 = b.bxor(r1, r2);
666	b.bxor(x1, r3)
667}
668
669/// Σ1(e)       = XOR( XOR( ROTR(x,  6), ROTR(x, 11) ), ROTR(x, 25) )
670fn big_sigma_1(b: &CircuitBuilder, x: Wire) -> Wire {
671	let r1 = b.rotr32(x, 6);
672	let r2 = b.rotr32(x, 11);
673	let r3 = b.rotr32(x, 25);
674	let x1 = b.bxor(r1, r2);
675	b.bxor(x1, r3)
676}
677
678/// σ0(x)       = XOR( XOR( ROTR(x,  7), ROTR(x, 18) ), SHR(x,  3) )
679fn small_sigma_0(b: &CircuitBuilder, x: Wire) -> Wire {
680	let r1 = b.rotr32(x, 7);
681	let r2 = b.rotr32(x, 18);
682	let s1 = b.srl32(x, 3);
683	let x1 = b.bxor(r1, r2);
684	b.bxor(x1, s1)
685}
686
687/// σ1(x)       = XOR( XOR( ROTR(x, 17), ROTR(x, 19) ), SHR(x, 10) )
688fn small_sigma_1(b: &CircuitBuilder, x: Wire) -> Wire {
689	let r1 = b.rotr32(x, 17);
690	let r2 = b.rotr32(x, 19);
691	let s1 = b.srl32(x, 10);
692	let x1 = b.bxor(r1, r2);
693	b.bxor(x1, s1)
694}
695
696#[cfg(test)]
697mod tests {
698	use binius_core::word::Word;
699	use binius_frontend::{CircuitBuilder, CircuitStat, Hint, Wire};
700	use proptest::prelude::*;
701
702	use super::{
703		IV, Sha256Compress2x, State, compress_2x_gates, populate_message_block, ref_compress,
704		sha256_compress, sha256_compress_2x, sha256_compress_2x_seq,
705	};
706
707	/// AND constraints one 32-bit SHA-256 compression cannot go below.
708	///
709	/// XOR, rotation and shift are affine, so they cost nothing.
710	/// Only the AND of two affine forms is witnessed, and each one eliminates exactly one summand:
711	///
712	/// ```text
713	///     n-summand modular sum          n - 1
714	///     Ch, Maj                            1  each
715	/// ```
716	///
717	/// A compression's nonlinear work is therefore fixed:
718	///
719	/// ```text
720	///     schedule    48 words x 3 sums                      144
721	///     rounds      64 x (Ch + Maj + 7 sums)               576
722	///     feedforward 8 sums                                   8
723	///                                                       ----
724	///                                                        728
725	/// ```
726	///
727	/// A gate constrains both 32-bit halves of its word at once, so two lanes share that total.
728	const AND_PER_COMPRESSION: usize = 364;
729
730	/// AND constraints a circuit spends on the state `f` returns.
731	///
732	/// The output is bound to public wires, since dead code elimination would drop it otherwise.
733	/// Binding is affine, so it lands on ZERO constraints and leaves the AND count alone.
734	fn and_cost(f: impl FnOnce(&CircuitBuilder) -> State) -> usize {
735		let builder = CircuitBuilder::new();
736		let out = f(&builder);
737		for wire in out.0 {
738			let public = builder.add_inout();
739			builder.assert_eq("out", wire, public);
740		}
741		CircuitStat::collect(&builder.build()).n_and_constraints
742	}
743
744	#[test]
745	fn a_compression_spends_only_its_nonlinear_work() {
746		let inputs = |b: &CircuitBuilder| {
747			let state = State::public(b);
748			let m: [Wire; 16] = std::array::from_fn(|_| b.add_inout());
749			(state, m)
750		};
751
752		// One compression across two lanes: 32 rounds each, and paired feed-forward words.
753		let single = and_cost(|b| {
754			let (state, m) = inputs(b);
755			sha256_compress(b, state, m)
756		});
757		assert_eq!(single, AND_PER_COMPRESSION);
758
759		// Two compressions across two lanes: one lane each, all 64 rounds.
760		let paired = and_cost(|b| {
761			let (state, m) = inputs(b);
762			sha256_compress_2x(b, state, m)
763		});
764		assert_eq!(paired, 2 * AND_PER_COMPRESSION);
765
766		// Chaining the pair costs no more: the hint that breaks the dependency is affine to bind.
767		let chained = and_cost(|b| {
768			let (state, _) = inputs(b);
769			let blocks: [[Wire; 16]; 2] =
770				std::array::from_fn(|_| std::array::from_fn(|_| b.add_inout()));
771			sha256_compress_2x_seq(b, state, blocks)
772		});
773		assert_eq!(chained, 2 * AND_PER_COMPRESSION);
774	}
775
776	/// A test circuit that proves a knowledge of preimage for a given state vector S in
777	///
778	///     compress512(preimage) = S
779	///
780	/// without revealing the preimage, only S.
781	#[test]
782	fn proof_preimage() {
783		// Use the test-vector for SHA256 single block message: "abc".
784		let mut preimage: [u8; 64] = [0; 64];
785		preimage[0..3].copy_from_slice(b"abc");
786		preimage[3] = 0x80;
787		preimage[63] = 0x18;
788
789		#[rustfmt::skip]
790		let expected_state: [u32; 8] = [
791			0xba7816bf, 0x8f01cfea, 0x414140de, 0x5dae2223,
792			0xb00361a3, 0x96177a9c, 0xb410ff61, 0xf20015ad,
793		];
794
795		let circuit = CircuitBuilder::new();
796		let state = State::iv(&circuit);
797		let input: [Wire; 16] = std::array::from_fn(|_| circuit.add_witness());
798		let output: [Wire; 8] = std::array::from_fn(|_| circuit.add_inout());
799		let state_out = sha256_compress(&circuit, state, input);
800
801		// Mask to only low 32-bit.
802		let mask32 = circuit.add_constant(Word::MASK_32);
803		for (i, (actual_x, expected_x)) in state_out.0.iter().zip(output).enumerate() {
804			circuit.assert_eq(
805				format!("preimage_eq[{i}]"),
806				circuit.band(*actual_x, mask32),
807				expected_x,
808			);
809		}
810
811		let circuit = circuit.build();
812		let cs = circuit.constraint_system();
813		let mut w = circuit.new_witness_filler();
814
815		// Populate the input message for the compression function.
816		populate_message_block(&mut w, &input, preimage);
817
818		for (i, &output) in output.iter().enumerate() {
819			w[output] = Word(expected_state[i] as u64);
820		}
821		circuit.populate_wire_witness(&mut w).unwrap();
822
823		cs.verify(&w.into_value_vec()).unwrap();
824	}
825
826	#[test]
827	fn sha256_chain() {
828		// Tests multiple SHA-256 compress512 invocations where the outputs are linked to the inputs
829		// of the following compression function.
830		const N: usize = 3;
831		let circuit = CircuitBuilder::new();
832
833		let mut m_vec = Vec::with_capacity(N);
834
835		// First, declare the initial state.
836		let mut state = State::iv(&circuit);
837		for i in 0..N {
838			// Create a new subcircuit builder. This is not necessary but can improve readability
839			// and diagnostics.
840			let sha256_builder = circuit.subcircuit(format!("sha256[{i}]"));
841
842			// Build a new instance of the sha256 verification subcircuit, passing the inputs `m` to
843			// it. For the first compression `m` is public but everything else if private.
844			let m: [Wire; 16] = if i == 0 {
845				std::array::from_fn(|_| sha256_builder.add_inout())
846			} else {
847				std::array::from_fn(|_| sha256_builder.add_witness())
848			};
849			state = sha256_compress(&sha256_builder, state, m);
850
851			m_vec.push(m);
852		}
853
854		let circuit = circuit.build();
855		let cs = circuit.constraint_system();
856		let mut w = circuit.new_witness_filler();
857
858		for m in &m_vec {
859			populate_message_block(&mut w, m, [0; 64]);
860		}
861		circuit.populate_wire_witness(&mut w).unwrap();
862
863		cs.verify(&w.into_value_vec()).unwrap();
864	}
865
866	#[test]
867	fn sha256_parallel() {
868		// Test multiple SHA-256 compressions in parallel (no chaining)
869		const N: usize = 3;
870		let circuit = CircuitBuilder::new();
871
872		let mut m_vec = Vec::with_capacity(N);
873
874		for i in 0..N {
875			// Create a new subcircuit builder
876			let sha256_builder = circuit.subcircuit(format!("sha256[{i}]"));
877
878			// Each SHA-256 instance gets its own IV and input (all committed)
879			let state = State::iv(&sha256_builder);
880			let m: [Wire; 16] = std::array::from_fn(|_| sha256_builder.add_inout());
881			sha256_compress(&sha256_builder, state, m);
882
883			m_vec.push(m);
884		}
885
886		let circuit = circuit.build();
887		let cs = circuit.constraint_system();
888		let mut w = circuit.new_witness_filler();
889
890		for m in &m_vec {
891			populate_message_block(&mut w, m, [0; 64]);
892		}
893		circuit.populate_wire_witness(&mut w).unwrap();
894
895		cs.verify(&w.into_value_vec()).unwrap();
896	}
897
898	/// Runs a single compression over one block and returns its raw 64-bit output words.
899	///
900	/// `dirt` is OR'd into the high 32 bits of every input wire.
901	/// Nothing range-constrains a wire holding a 32-bit value, so a witness may fill its high half.
902	fn run_compress_with_dirt(state_in: [u32; 8], m: [u32; 16], dirt: u32) -> [u64; 8] {
903		let dirt = (dirt as u64) << 32;
904
905		let circuit = CircuitBuilder::new();
906
907		// Witness wires, so the dirt written below is what a prover could really supply.
908		let state_wires: [Wire; 8] = std::array::from_fn(|_| circuit.add_witness());
909		let m_wires: [Wire; 16] = std::array::from_fn(|_| circuit.add_witness());
910		let out = sha256_compress(&circuit, State::new(state_wires), m_wires);
911
912		// Pin the output, or dead-code elimination prunes the compression away.
913		for wire in out.0 {
914			circuit.mark_inout(wire);
915		}
916
917		let circuit = circuit.build();
918		let cs = circuit.constraint_system();
919		let mut w = circuit.new_witness_filler();
920		for i in 0..8 {
921			w[state_wires[i]] = Word(state_in[i] as u64 | dirt);
922		}
923		for i in 0..16 {
924			w[m_wires[i]] = Word(m[i] as u64 | dirt);
925		}
926
927		// Population runs the hint and fills every wire from it.
928		circuit.populate_wire_witness(&mut w).unwrap();
929		let words: [u64; 8] = std::array::from_fn(|i| w[out.0[i]].as_u64());
930
931		// Checking afterwards is what proves the hint was bound, not merely used.
932		cs.verify(&w.into_value_vec()).unwrap();
933		words
934	}
935
936	/// A state that shares no structure with the IV, so a lane mix-up cannot pass unnoticed.
937	const NONTRIVIAL_STATE: [u32; 8] = [
938		0xdead_beef,
939		0xcafe_babe,
940		0x1234_5678,
941		0x9abc_def0,
942		0x0bad_f00d,
943		0xfeed_face,
944		0x0123_4567,
945		0x89ab_cdef,
946	];
947
948	#[test]
949	fn compress_ignores_dirty_input_high_halves() {
950		// Invariant: the result is the compression of the low halves alone.
951		//
952		// Both phases run in both halves of every word.
953		// So the half a 32-bit input leaves unused is where half of the mixing happens.
954		//
955		//     dirty high half  ->  must not steer the result
956		//     output           ->  must come back with an empty high half
957		let m: [u32; 16] = std::array::from_fn(|i| (i as u32).wrapping_mul(0xdead_beef));
958		let expected = ref_compress(NONTRIVIAL_STATE, m);
959
960		for dirt in [0, 1, 0x8000_0000, 0xffff_ffff] {
961			let actual = run_compress_with_dirt(NONTRIVIAL_STATE, m, dirt);
962			assert_eq!(actual, expected.map(u64::from), "dirt {dirt:#x} changed the result");
963		}
964	}
965
966	proptest! {
967		// The two lanes carry different halves of one compression, joined by a hint.
968		// Random states and blocks cover the carry and rotate patterns the split must survive.
969		#[test]
970		fn compress_matches_reference(
971			state_words in prop::collection::vec(any::<u32>(), 8),
972			block_words in prop::collection::vec(any::<u32>(), 16),
973		) {
974			let state_in: [u32; 8] = std::array::from_fn(|i| state_words[i]);
975			let m: [u32; 16] = std::array::from_fn(|i| block_words[i]);
976
977			let actual = run_compress_with_dirt(state_in, m, 0);
978			prop_assert_eq!(actual, ref_compress(state_in, m).map(u64::from));
979		}
980	}
981
982	fn pack2x(lo: u32, hi: u32) -> u64 {
983		(lo as u64) | ((hi as u64) << 32)
984	}
985
986	/// Runs `sha256_compress_2x` with lane 0 = `(state0, m0)` and lane 1 = `(state1, m1)`, and
987	/// asserts each output lane against [`ref_compress`].
988	fn run_2x(state0: [u32; 8], m0: [u32; 16], state1: [u32; 8], m1: [u32; 16]) {
989		let exp0 = ref_compress(state0, m0);
990		let exp1 = ref_compress(state1, m1);
991
992		let circuit = CircuitBuilder::new();
993		let state_in: [Wire; 8] = std::array::from_fn(|_| circuit.add_witness());
994		let m: [Wire; 16] = std::array::from_fn(|_| circuit.add_witness());
995		let out = sha256_compress_2x(&circuit, State::new(state_in), m);
996		let out_inout: [Wire; 8] = std::array::from_fn(|_| circuit.add_inout());
997		for i in 0..8 {
998			circuit.assert_eq(format!("out[{i}]"), out.0[i], out_inout[i]);
999		}
1000
1001		let circuit = circuit.build();
1002		let cs = circuit.constraint_system();
1003		let mut w = circuit.new_witness_filler();
1004		for i in 0..8 {
1005			w[state_in[i]] = Word(pack2x(state0[i], state1[i]));
1006		}
1007		for i in 0..16 {
1008			w[m[i]] = Word(pack2x(m0[i], m1[i]));
1009		}
1010		for i in 0..8 {
1011			w[out_inout[i]] = Word(pack2x(exp0[i], exp1[i]));
1012		}
1013		circuit.populate_wire_witness(&mut w).unwrap();
1014
1015		cs.verify(&w.into_value_vec()).unwrap();
1016	}
1017
1018	/// The single-block padding of the message "abc": `"abc" || 0x80 || 0*` with the 24-bit length
1019	/// in the final word.
1020	fn abc_block() -> [u32; 16] {
1021		let mut m = [0u32; 16];
1022		m[0] = 0x6162_6380;
1023		m[15] = 0x0000_0018;
1024		m
1025	}
1026
1027	/// Known SHA-256 digest of "abc" (also the compression output for the single ABC block).
1028	const ABC_DIGEST: [u32; 8] = [
1029		0xba78_16bf,
1030		0x8f01_cfea,
1031		0x4141_40de,
1032		0x5dae_2223,
1033		0xb003_61a3,
1034		0x9617_7a9c,
1035		0xb410_ff61,
1036		0xf200_15ad,
1037	];
1038
1039	#[test]
1040	fn compress_2x_distinct_lanes() {
1041		// Anchor the reference: lane 0 compresses the "abc" block from the IV, which must yield the
1042		// known SHA-256 digest of "abc".
1043		assert_eq!(ref_compress(IV, abc_block()), ABC_DIGEST);
1044
1045		// Lane 1 runs a completely different (state, message) pair to confirm the lanes are
1046		// independent — no bits cross the 32-bit boundary.
1047		let state1 = NONTRIVIAL_STATE;
1048		let m1: [u32; 16] = std::array::from_fn(|i| (i as u32).wrapping_mul(0x0101_0101));
1049
1050		run_2x(IV, abc_block(), state1, m1);
1051	}
1052
1053	#[test]
1054	fn compress_2x_lane_independence() {
1055		// Lane 0 = "abc", lane 1 = an all-zero block from the IV. Each lane must match its own
1056		// reference, proving the zero lane does not perturb the "abc" lane and vice versa.
1057		run_2x(IV, abc_block(), IV, [0; 16]);
1058	}
1059
1060	/// Runs the sequential two-block compression and checks it against the scalar reference.
1061	///
1062	/// The reference chains two single-block compressions:
1063	///
1064	/// ```text
1065	///     S1 = compress(state_in, block1)   → high lane
1066	///     S2 = compress(S1,       block2)   → low lane
1067	/// ```
1068	fn run_2x_seq(state_in: [u32; 8], block1: [u32; 16], block2: [u32; 16]) {
1069		// Scalar reference: the second compression consumes the first's output as its input state.
1070		let s1 = ref_compress(state_in, block1);
1071		let s2 = ref_compress(s1, block2);
1072
1073		// Fresh witness wires for the input state and both message blocks.
1074		let circuit = CircuitBuilder::new();
1075		let state_wires: [Wire; 8] = std::array::from_fn(|_| circuit.add_witness());
1076		let block1_wires: [Wire; 16] = std::array::from_fn(|_| circuit.add_witness());
1077		let block2_wires: [Wire; 16] = std::array::from_fn(|_| circuit.add_witness());
1078
1079		// Gadget under test: one parallel core evaluating both sequential compressions.
1080		let out =
1081			sha256_compress_2x_seq(&circuit, State::new(state_wires), [block1_wires, block2_wires]);
1082
1083		// Pin the packed output to public wires so dead-code elimination keeps the computation.
1084		let out_inout: [Wire; 8] = std::array::from_fn(|_| circuit.add_inout());
1085		for i in 0..8 {
1086			circuit.assert_eq(format!("out[{i}]"), out.0[i], out_inout[i]);
1087		}
1088
1089		let circuit = circuit.build();
1090		let cs = circuit.constraint_system();
1091		let mut w = circuit.new_witness_filler();
1092
1093		// Each 32-bit input goes in the low half of its wire; the high half stays empty.
1094		for i in 0..8 {
1095			w[state_wires[i]] = Word(state_in[i] as u64);
1096		}
1097		for i in 0..16 {
1098			w[block1_wires[i]] = Word(block1[i] as u64);
1099			w[block2_wires[i]] = Word(block2[i] as u64);
1100		}
1101
1102		// Expected packing per word: low 32 bits = second output, high 32 bits = first output.
1103		for i in 0..8 {
1104			w[out_inout[i]] = Word(pack2x(s2[i], s1[i]));
1105		}
1106
1107		// Witness generation runs the hint; constraint checking then verifies every gate.
1108		circuit.populate_wire_witness(&mut w).unwrap();
1109		cs.verify(&w.into_value_vec()).unwrap();
1110	}
1111
1112	/// Packs 64 message bytes into 16 big-endian 32-bit schedule words.
1113	fn pack_block_be(bytes: &[u8; 64]) -> [u32; 16] {
1114		std::array::from_fn(|i| u32::from_be_bytes(bytes[i * 4..i * 4 + 4].try_into().unwrap()))
1115	}
1116
1117	#[test]
1118	fn compress_2x_seq_matches_rfc_two_block_kat() {
1119		// Known-answer test: RFC 6234 SHA-256 TEST2_1.
1120		// A 56-byte message spans two blocks after padding, so the digest is a chained compression.
1121		// Chaining both blocks from the IV must reproduce the published digest.
1122		let msg = b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
1123
1124		// Padded layout over 128 bytes = two blocks:
1125		//     bytes 0..56   : the message.
1126		//     byte  56      : the 0x80 delimiter.
1127		//     bytes 57..120 : zero fill.
1128		//     bytes 120..128: the 64-bit message bit length (56 * 8 = 448).
1129		let mut padded = [0u8; 128];
1130		padded[..56].copy_from_slice(msg);
1131		padded[56] = 0x80;
1132		padded[120..128].copy_from_slice(&(56u64 * 8).to_be_bytes());
1133		let block0 = pack_block_be(padded[0..64].try_into().unwrap());
1134		let block1 = pack_block_be(padded[64..128].try_into().unwrap());
1135
1136		// Published digest (RFC 6234, SHA-256 TEST2_1).
1137		let expected: [u32; 8] = [
1138			0x248d_6a61,
1139			0xd206_38b8,
1140			0xe5c0_2693,
1141			0x0c3e_6039,
1142			0xa33c_e459,
1143			0x64ff_2167,
1144			0xf6ec_edd4,
1145			0x19db_06c1,
1146		];
1147
1148		// The chaining value the first block hands to the second, published with the same vector.
1149		//
1150		// Why anchor the boundary and not only the digest:
1151		// - The high lane must reproduce exactly this value.
1152		// - The low lane must chain from it.
1153		// - So this is what pins the hinted state carried between the two lanes.
1154		let expected_s1: [u32; 8] = [
1155			0x85e6_55d6,
1156			0x417a_1795,
1157			0x3363_376a,
1158			0x624c_de5c,
1159			0x76e0_9589,
1160			0xcac5_f811,
1161			0xcc4b_32c1,
1162			0xf20e_533a,
1163		];
1164
1165		// The second compression's output is the message digest; anchor it to the RFC vector.
1166		let s1 = ref_compress(IV, block0);
1167		assert_eq!(s1, expected_s1);
1168		let s2 = ref_compress(s1, block1);
1169		assert_eq!(s2, expected);
1170
1171		// Run the same two-block chain in-circuit and cross-check both lanes.
1172		run_2x_seq(IV, block0, block1);
1173	}
1174
1175	#[test]
1176	fn compress_2x_seq_distinct_params() {
1177		// Invariant: the hinted first output is bound twice.
1178		//     once as the second compression's input state.
1179		//     once against the first compression's in-circuit output.
1180		//
1181		// Fixture: a non-IV starting state and two unrelated blocks exercise the full lane packing.
1182		let state_in = NONTRIVIAL_STATE;
1183		let block1: [u32; 16] = std::array::from_fn(|i| (i as u32).wrapping_mul(0xdead_beef));
1184		let block2: [u32; 16] = std::array::from_fn(|i| (i as u32).wrapping_mul(0x0101_0101));
1185		run_2x_seq(state_in, block1, block2);
1186	}
1187
1188	/// Runs `sha256_compress_2x` in gates over its flat packed-word interface.
1189	fn run_compress_2x_words(inputs: [u64; 24]) -> [u64; 8] {
1190		let builder = CircuitBuilder::new();
1191		let wires: [Wire; 24] = std::array::from_fn(|_| builder.add_witness());
1192		let out = compress_2x_gates(
1193			&builder,
1194			State(std::array::from_fn(|i| wires[i])),
1195			std::array::from_fn(|i| wires[8 + i]),
1196		);
1197		for wire in out.0 {
1198			builder.mark_inout(wire);
1199		}
1200
1201		let circuit = builder.build();
1202		let mut w = circuit.new_witness_filler();
1203		for (wire, word) in std::iter::zip(wires, inputs) {
1204			w[wire] = Word(word);
1205		}
1206		circuit.populate_wire_witness(&mut w).unwrap();
1207
1208		std::array::from_fn(|i| w[out.0[i]].as_u64())
1209	}
1210
1211	proptest! {
1212		// The chip path takes the outputs from `Sha256Compress2x::execute` and the chip recomputes
1213		// them from its gates, so the two have to agree on every word a circuit can reach them
1214		// with. Any 64-bit word is a valid lane pair here, so random words cover the whole
1215		// interface.
1216		#[test]
1217		fn compress_2x_hint_matches_its_gates(words in prop::collection::vec(any::<u64>(), 24)) {
1218			let inputs: [u64; 24] = std::array::from_fn(|i| words[i]);
1219
1220			let mut hinted = [Word::ZERO; 8];
1221			Sha256Compress2x.execute(&[], &inputs.map(Word), &mut hinted);
1222
1223			prop_assert_eq!(hinted.map(|word| word.as_u64()), run_compress_2x_words(inputs));
1224		}
1225	}
1226}