Skip to main content

binius_spartan_verifier/wrapper/
zk_wrapped_channel.rs

1// Copyright 2026 The Binius Developers
2
3//! ZK-wrapped verifier channel that delegates to a BaseFold ZK channel and an outer IOP verifier.
4//!
5//! [`ZKWrappedVerifierChannel`] wraps a [`BaseFoldVerifierChannel`] and an [`IOPVerifier`].
6//! Inner-channel values flow through the wrapper as `CircuitElem`s backed by an
7//! [`InstanceGenerator`], which reconstructs the outer constraint system's public-input vector
8//! `[constants | inout | derived]` exactly as the prover's witness generator does — public-derived
9//! intermediate values are recomputed natively rather than tracked by the wrapper. [`finish()`]
10//! hands that public vector to the outer verifier and runs it against the inner channel.
11//!
12//! [`finish()`]: ZKWrappedVerifierChannel::finish
13
14use std::{cell::RefCell, rc::Rc, sync::Arc};
15
16use binius_core::word::Word;
17use binius_field::BinaryField;
18use binius_iop::{
19	basefold::channel::{BaseFoldOracle, BaseFoldVerifierChannel},
20	channel::{IOPVerifierChannel, OracleSpec, TransparentEvalFn},
21	merkle_channel::MerkleIPVerifierChannel,
22};
23use binius_ip::channel::{
24	IPVerifierChannel, WordIPVerifierChannel, pack_words_concrete, select_word, subset_sum_word,
25};
26use binius_spartan_frontend::{
27	circuit_builder::{InstanceGenerator, WireAllocator},
28	constraint_system::{WireKind, WitnessLayout},
29};
30
31use crate::{Error, IOPVerifier, wrapper::circuit_elem::CircuitElem};
32
33/// A verifier channel that wraps a [`BaseFoldVerifierChannel`] and an [`IOPVerifier`].
34///
35/// `Self::Elem = CircuitElem<F, InstanceGenerator>`. F values received or sampled from the inner
36/// channel are written into the [`InstanceGenerator`]'s public segment as inout wires (in the same
37/// order the symbolic
38/// [`IronSpartanBuilderChannel`](super::builder_channel::IronSpartanBuilderChannel) allocates
39/// them); arithmetic over public values produces derived public values, and mixing with a precommit
40/// key (as `recv_one` arranges via `inout - key`) yields a value-less private result.
41///
42/// `transparent` closures supplied to [`IOPVerifierChannel::verify_oracle_relation`] must depend
43/// only on public inputs (constants and sampled challenges), never on private ones — that method
44/// panics otherwise.
45pub struct ZKWrappedVerifierChannel<'a, F, Channel>
46where
47	F: BinaryField,
48	Channel: MerkleIPVerifierChannel<F, Elem = F>,
49{
50	inner_channel: BaseFoldVerifierChannel<'a, F, Channel>,
51	outer_verifier: &'a IOPVerifier<F>,
52	precommit_oracle: BaseFoldOracle,
53	/// Reconstructs the outer public-input vector as the channel replays the inner verifier;
54	/// `build()` yields the `[constants | inout | derived]` segment for the outer verify.
55	instance_gen: Rc<RefCell<InstanceGenerator<F>>>,
56	/// Allocators for the InOut and Precommit segments. They live here, not on the
57	/// [`InstanceGenerator`], because allocating wires in interaction order is the channel's job;
58	/// the generator just writes a value to a given wire. Allocation order must match the symbolic
59	/// [`IronSpartanBuilderChannel`](super::builder_channel::IronSpartanBuilderChannel) so the
60	/// wire ids align with the outer layout.
61	inout_alloc: WireAllocator,
62	precommit_alloc: WireAllocator,
63	/// Number of outer oracles still to be received on `inner_channel` after inner verification
64	/// completes (i.e. the outer verifier's non-precommit oracles — private and mask).
65	n_outer_suffix_oracles: usize,
66}
67
68impl<'a, F, Channel> ZKWrappedVerifierChannel<'a, F, Channel>
69where
70	F: BinaryField,
71	Channel: MerkleIPVerifierChannel<F, Elem = F>,
72{
73	/// Creates a new ZK-wrapped verifier channel.
74	///
75	/// The outer verifier's oracle specs are expected to straddle the inner channel specs:
76	/// the outer precommit spec is at position 0 (committed before any inner interaction), and
77	/// the remaining outer specs (private, mask) form a suffix that will be received after the
78	/// inner verification completes. `new` receives the outer precommit oracle from the inner
79	/// channel and stores the handle for use in [`Self::finish`].
80	///
81	/// `outer_layout` is the witness layout of the outer constraint system (the same layout the
82	/// prover used); it backs the [`InstanceGenerator`] that reconstructs the public-input vector.
83	/// It is a shared `Arc`, not a borrow, so the generator is `'static`.
84	/// Its `CircuitElem`s live in the transparent closures queued onto `inner_channel`.
85	/// The `Arc` shares the layout the config owns rather than cloning it.
86	///
87	/// # Panics
88	///
89	/// Panics if the channel's oracle specs do not match the expected layout
90	/// `[outer_precommit, inner..., outer_private, outer_mask]`.
91	pub fn new(
92		mut inner_channel: BaseFoldVerifierChannel<'a, F, Channel>,
93		outer_verifier: &'a IOPVerifier<F>,
94		outer_layout: Arc<WitnessLayout<F>>,
95	) -> Result<Self, Error> {
96		let outer_oracle_specs = outer_verifier.oracle_specs();
97		let channel_oracle_specs = inner_channel.remaining_oracle_specs();
98
99		let n_outer = outer_oracle_specs.len();
100		let n_total = channel_oracle_specs.len();
101		assert!(
102			n_outer >= 1 && n_outer <= n_total,
103			"outer oracle specs ({n_outer}) exceed channel oracle specs ({n_total}) or are empty"
104		);
105		assert_eq!(
106			channel_oracle_specs[0], outer_oracle_specs[0],
107			"outer precommit oracle spec must be the first spec on the channel"
108		);
109		let suffix_len = n_outer - 1;
110		assert_eq!(
111			&channel_oracle_specs[n_total - suffix_len..],
112			&outer_oracle_specs[1..],
113			"outer private/mask oracle specs must be the final suffix of channel specs"
114		);
115
116		let precommit_oracle =
117			inner_channel.recv_oracle(outer_oracle_specs[0].log_msg_len, true)?;
118
119		Ok(Self {
120			inner_channel,
121			outer_verifier,
122			precommit_oracle,
123			instance_gen: Rc::new(RefCell::new(InstanceGenerator::new(outer_layout))),
124			inout_alloc: WireAllocator::new(WireKind::InOut),
125			precommit_alloc: WireAllocator::new(WireKind::Precommit),
126			n_outer_suffix_oracles: suffix_len,
127		})
128	}
129
130	/// Allocates the next inout wire, writing `value` into the public segment, and wraps it as an
131	/// element. Allocation order must match the symbolic
132	/// [`IronSpartanBuilderChannel`](super::builder_channel::IronSpartanBuilderChannel).
133	fn alloc_inout_elem(&mut self, value: F) -> CircuitElem<F, InstanceGenerator<F>> {
134		let wire = self.inout_alloc.alloc();
135		let public_wire = self.instance_gen.borrow_mut().write_inout(wire, value);
136		CircuitElem::wire(&self.instance_gen, public_wire)
137	}
138
139	/// The value of an element, when the verifier holds it.
140	///
141	/// A constant, an inout wire, or anything derived from those alone has a value here; an element
142	/// that reads a precommit wire has none. This is what lets a check over public values run
143	/// outside the wrapper circuit — the verifier evaluates it directly instead of constraining it.
144	pub const fn public_value(&self, elem: &CircuitElem<F, InstanceGenerator<F>>) -> Option<F> {
145		match elem {
146			CircuitElem::Constant(val) => Some(*val),
147			CircuitElem::Wire { wire, .. } => wire.value(),
148		}
149	}
150
151	/// Allocates the next precommit wire (value-less to the verifier) as an element.
152	fn alloc_precommit_elem(&mut self) -> CircuitElem<F, InstanceGenerator<F>> {
153		let wire = self.precommit_alloc.alloc();
154		let public_wire = self.instance_gen.borrow_mut().placeholder_precommit(wire);
155		CircuitElem::wire(&self.instance_gen, public_wire)
156	}
157
158	/// Consumes the channel and runs the outer verifier.
159	///
160	/// Reads the outer public-input vector `[constants | inout | derived]` from the
161	/// [`InstanceGenerator`] and runs [`IOPVerifier::verify`] against the inner channel.
162	pub fn finish(self) -> Result<(), Error> {
163		// The instance generator produced every public value (inout + alive-derived) as the inner
164		// verifier ran, so the public segment is already final here — read it by borrow. We must
165		// NOT consume the generator: the oracle relations queued onto `inner_channel` carry
166		// transparent closures that still hold (`Weak`) references to it, and opening them in
167		// `inner_channel.finish()` evaluates those closures. They only allocate dead derived wires
168		// (ids past the layout's count, so `layout.get` returns `None` and nothing is written), so
169		// the public vector read here stays correct.
170		let public = self.instance_gen.borrow().public().to_vec();
171
172		let mut inner_channel = self.inner_channel;
173		self.outer_verifier
174			.verify(self.precommit_oracle, &public, &mut inner_channel)?;
175		// Both the inner and outer proofs queued their oracle relations onto `inner_channel`; run
176		// the single combined opening over all committed oracles now. `instance_gen` stays alive in
177		// `self` for the duration, so the transparent closures' `Weak` upgrades succeed.
178		inner_channel.finish()?;
179		Ok(())
180	}
181}
182
183impl<'a, F, Channel> IPVerifierChannel<F> for ZKWrappedVerifierChannel<'a, F, Channel>
184where
185	F: BinaryField,
186	Channel: MerkleIPVerifierChannel<F, Elem = F>,
187{
188	type Elem = CircuitElem<F, InstanceGenerator<F>>;
189
190	fn recv_one(&mut self) -> Result<Self::Elem, binius_ip::channel::Error> {
191		// Mirror `IronSpartanBuilderChannel::recv_one`'s shape: `inout - key`. The inout carries
192		// the encrypted F received from the inner channel (written into the public segment); the
193		// key is a precommit wire whose value the verifier does not know (`PublicWire(None)`).
194		// The subtraction yields a private result, matching the symbolic phase's private result
195		// wire.
196		let val = self.inner_channel.recv_one()?;
197		let inout = self.alloc_inout_elem(val);
198		let key = self.alloc_precommit_elem();
199		Ok(inout - key)
200	}
201
202	fn recv_public_claim(&mut self) -> Result<Self::Elem, binius_ip::channel::Error> {
203		// Mirror `IronSpartanBuilderChannel::recv_public_claim`: the value arrives unencrypted, so
204		// it enters as one inout wire carrying it, with no precommit key to subtract.
205		let val = self.inner_channel.recv_one()?;
206		Ok(self.alloc_inout_elem(val))
207	}
208
209	fn sample(&mut self) -> Self::Elem {
210		let val = self.inner_channel.sample();
211		self.alloc_inout_elem(val)
212	}
213
214	fn observe_one(&mut self, val: F) -> Self::Elem {
215		let elem = self.inner_channel.observe_one(val);
216		self.alloc_inout_elem(elem)
217	}
218
219	fn assert_zero(&mut self, val: Self::Elem) -> Result<(), binius_ip::channel::Error> {
220		match val {
221			// A compile-time constant is checked here; a non-zero one is an unsatisfiable
222			// assertion.
223			CircuitElem::Constant(c) => {
224				if c == F::ZERO {
225					Ok(())
226				} else {
227					Err(binius_ip::channel::Error::InvalidAssert)
228				}
229			}
230			// No-op for wires: the corresponding constraint was recorded symbolically and is
231			// checked by the outer verifier over the reconstructed public segment.
232			CircuitElem::Wire { .. } => Ok(()),
233		}
234	}
235}
236
237impl<F, Channel> WordIPVerifierChannel<F> for ZKWrappedVerifierChannel<'_, F, Channel>
238where
239	F: BinaryField,
240	Channel: MerkleIPVerifierChannel<F, Elem = F, Word = Word>,
241{
242	type Word = Word;
243
244	fn observe_words(&mut self, words: &[Word]) -> Vec<Word> {
245		// The inner channel holds the Fiat-Shamir state the inner prover mirrors, so the words go
246		// there. The outer verifier recomputes what depends on them from the public segment.
247		self.inner_channel.observe_words(words)
248	}
249
250	fn subset_sum(&mut self, elems: &[Self::Elem], word: &Word) -> Self::Elem {
251		subset_sum_word(elems, *word)
252	}
253
254	fn select(&mut self, elems: &[Self::Elem], word: &Word) -> Self::Elem {
255		select_word(elems, *word)
256	}
257
258	fn sample_bits(&mut self, bits: usize) -> Word {
259		self.inner_channel.sample_bits(bits)
260	}
261
262	fn pack_words(&mut self, words: &[Word]) -> Vec<Self::Elem> {
263		// The symbolic phase allocated an inout wire per packed element, since the statement is not
264		// the circuit's to fix. Fill them here: the words are concrete, so the verifier packs them
265		// itself and writes the result into the public segment.
266		pack_words_concrete::<F, F>(words)
267			.into_iter()
268			.map(|value| self.alloc_inout_elem(value))
269			.collect()
270	}
271}
272
273impl<'a, F, Channel> IOPVerifierChannel<F> for ZKWrappedVerifierChannel<'a, F, Channel>
274where
275	F: BinaryField,
276	Channel: MerkleIPVerifierChannel<F, Elem = F>,
277{
278	type Oracle = BaseFoldOracle;
279
280	fn remaining_oracle_specs(&self) -> &[OracleSpec] {
281		let all = self.inner_channel.remaining_oracle_specs();
282		let n_remaining_inner = all.len() - self.n_outer_suffix_oracles;
283		&all[..n_remaining_inner]
284	}
285
286	fn recv_oracle(
287		&mut self,
288		log_msg_len: usize,
289		is_witness_dependent: bool,
290	) -> Result<Self::Oracle, binius_iop::channel::Error> {
291		assert!(
292			!self.remaining_oracle_specs().is_empty(),
293			"recv_oracle called but no remaining inner oracle specs"
294		);
295		self.inner_channel
296			.recv_oracle(log_msg_len, is_witness_dependent)
297	}
298
299	fn verify_oracle_relation(
300		&mut self,
301		oracle: Self::Oracle,
302		transparent: TransparentEvalFn<Self::Elem>,
303		claim: Self::Elem,
304	) -> Result<(), binius_iop::channel::Error> {
305		// For each oracle opening, the prover sends the decrypted evaluation. Allocate it as an
306		// inout wire (written into the public segment) and attest `claim == decrypted_claim`,
307		// exactly as the symbolic `IronSpartanBuilderChannel` does. The assertion is a no-op on
308		// values here, but evaluating `claim - decrypted_claim` keeps the instance generator's
309		// wire allocation aligned with the symbolic constraint system. The relation is passed on
310		// to the inner channel with the decrypted value.
311		let decrypted_value = self.inner_channel.recv_one()?;
312		let decrypted_claim = self.alloc_inout_elem(decrypted_value);
313		self.assert_zero(claim - decrypted_claim)?;
314
315		// Wrap the sumcheck challenge coordinates for the transparent closure (which expects
316		// `CircuitElem`s). The closure can do further arithmetic; results are required to be
317		// value-known (public), never private.
318		//
319		// HACK: the coordinates are sampled challenges, so they are wrapped as `Constant`s
320		// rather than builder-backed wires. This frees the closure from holding a reference to
321		// the instance generator, and is sound only because the symbolic outer circuit
322		// (`IronSpartanBuilderChannel`) never invokes the transparent closure — it attests only
323		// `claim == decrypted_claim`, with the transparent evaluation performed out of circuit.
324		// This F->CircuitElem->F bridge should eventually be replaced by an F-level transparent
325		// evaluator.
326		let eval_fn = move |vals: &[F]| {
327			let wrapped_vals = vals
328				.iter()
329				.map(|val| CircuitElem::Constant(*val))
330				.collect::<Vec<_>>();
331
332			match transparent(&wrapped_vals) {
333				CircuitElem::Constant(val) => val,
334				CircuitElem::Wire { wire, .. } => wire.value().expect(
335					"precondition: the transparent polynomial evaluation must depend only on known values (constants or sampled challenges)",
336				),
337			}
338		};
339		self.inner_channel
340			.verify_oracle_relation(oracle, Box::new(eval_fn), decrypted_value)
341	}
342}