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}