Skip to main content

binius_iop/channel/
size_tracking.rs

1// Copyright 2026 The Binius Developers
2
3//! A Merkle channel that counts proof bytes instead of verifying them.
4
5use std::{marker::PhantomData, mem::size_of};
6
7use binius_core::word::Word;
8use binius_field::BinaryField;
9use binius_ip::channel::{
10	IPVerifierChannel, WordIPVerifierChannel, pack_words_concrete, select_word, subset_sum_word,
11};
12use binius_utils::serialization::FixedSizeSerializeBytes;
13
14use crate::{
15	channel::grinding::GrindingVerifierChannel,
16	merkle_channel::{Error, MerkleIPVerifierChannel},
17	merkle_tree::MerkleTreeScheme,
18};
19
20/// What a commitment fixes about the tree behind it.
21///
22/// A commitment carries no data here, only the two numbers that decide what opening it costs.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct CommittedShape {
25	/// Field elements in each leaf.
26	leaf_size: usize,
27	/// Base-2 logarithm of the number of leaves.
28	depth: usize,
29}
30
31/// A Merkle channel that counts the bytes a proof would occupy, without checking any of them.
32///
33/// Every receive returns zeros and adds what the real bytes would weigh.
34/// Sampling returns zeros too, so nothing here needs a prover.
35///
36/// Counting at this layer measures a protocol by running it, rather than by a formula kept in
37/// step with it by hand.
38/// Anything speaking this interface can be measured, oracle reduction or not.
39///
40/// Zero survives every fold and equality check a verifier performs.
41/// So the verifier takes the path it would on a real proof, reaching the same receives in order.
42pub struct SizeTrackingChannel<'a, F, MerkleScheme_> {
43	scheme: &'a MerkleScheme_,
44	proof_size: usize,
45	_field_marker: PhantomData<F>,
46}
47
48impl<'a, F, MerkleScheme_> SizeTrackingChannel<'a, F, MerkleScheme_> {
49	/// Creates a channel that sizes openings for the given scheme.
50	pub const fn new(scheme: &'a MerkleScheme_) -> Self {
51		Self {
52			scheme,
53			proof_size: 0,
54			_field_marker: PhantomData,
55		}
56	}
57
58	/// The bytes counted so far.
59	pub const fn proof_size(&self) -> usize {
60		self.proof_size
61	}
62}
63
64impl<F, MerkleScheme_> IPVerifierChannel<F> for SizeTrackingChannel<'_, F, MerkleScheme_>
65where
66	F: BinaryField + FixedSizeSerializeBytes,
67	MerkleScheme_: MerkleTreeScheme<F>,
68{
69	// Nothing here reads a value, so a zero-sized stand-in would seem to fit.
70	// It does not: openings arrive as field elements, and folding mixes them with sampled
71	// challenges, so the two must be one type.
72	type Elem = F;
73
74	fn recv_one(&mut self) -> Result<F, binius_ip::channel::Error> {
75		self.proof_size += F::BYTE_SIZE;
76		Ok(F::ZERO)
77	}
78
79	fn recv_many(&mut self, n: usize) -> Result<Vec<F>, binius_ip::channel::Error> {
80		self.proof_size += n * F::BYTE_SIZE;
81		Ok(vec![F::ZERO; n])
82	}
83
84	fn recv_array<const N: usize>(&mut self) -> Result<[F; N], binius_ip::channel::Error> {
85		self.proof_size += N * F::BYTE_SIZE;
86		Ok([F::ZERO; N])
87	}
88
89	fn sample(&mut self) -> F {
90		F::ZERO
91	}
92
93	fn observe_one(&mut self, _val: F) -> F {
94		F::ZERO
95	}
96
97	fn observe_many(&mut self, vals: &[F]) -> Vec<F> {
98		vec![F::ZERO; vals.len()]
99	}
100
101	fn assert_zero(&mut self, _val: F) -> Result<(), binius_ip::channel::Error> {
102		Ok(())
103	}
104}
105
106impl<F, MerkleScheme_> WordIPVerifierChannel<F> for SizeTrackingChannel<'_, F, MerkleScheme_>
107where
108	F: BinaryField + FixedSizeSerializeBytes,
109	MerkleScheme_: MerkleTreeScheme<F>,
110{
111	type Word = Word;
112
113	// Observing feeds the Fiat-Shamir state rather than the proof tape, so it costs no bytes.
114	fn observe_words(&mut self, words: &[Word]) -> Vec<Word> {
115		words.to_vec()
116	}
117
118	fn subset_sum(&mut self, elems: &[F], word: &Word) -> F {
119		subset_sum_word(elems, *word)
120	}
121
122	fn select(&mut self, elems: &[F], word: &Word) -> F {
123		select_word(elems, *word)
124	}
125
126	// Which leaves are opened does not change what an opening costs, only how many.
127	fn sample_bits(&mut self, _bits: usize) -> Word {
128		Word::ZERO
129	}
130
131	fn pack_words(&mut self, words: &[Word]) -> Vec<F> {
132		pack_words_concrete::<F, F>(words)
133	}
134}
135
136impl<F, MerkleScheme_> GrindingVerifierChannel for SizeTrackingChannel<'_, F, MerkleScheme_> {
137	fn verify_grind(&mut self, bits: usize) -> Result<(), binius_transcript::Error> {
138		// Zero difficulty is not a grind, so nothing reaches the tape and nothing is charged.
139		if bits == 0 {
140			return Ok(());
141		}
142		// A grind puts one `u64` nonce on the tape, whatever its difficulty.
143		self.proof_size += size_of::<u64>();
144		Ok(())
145	}
146}
147
148impl<F, MerkleScheme_> MerkleIPVerifierChannel<F> for SizeTrackingChannel<'_, F, MerkleScheme_>
149where
150	F: BinaryField + FixedSizeSerializeBytes,
151	MerkleScheme_: MerkleTreeScheme<F>,
152{
153	type Commitment = CommittedShape;
154
155	fn recv_merkle_commitment(
156		&mut self,
157		leaf_size: usize,
158		depth: usize,
159	) -> Result<Self::Commitment, Error> {
160		// A commitment is its root, one digest wide.
161		self.proof_size += size_of::<MerkleScheme_::Digest>();
162		Ok(CommittedShape { leaf_size, depth })
163	}
164
165	fn recv_openings(
166		&mut self,
167		commitment: &Self::Commitment,
168		indices: &[Word],
169	) -> Result<Vec<F>, Error> {
170		// A multi-opening sends one internal layer, then one branch per index up to that layer.
171		// The scheme prices both, since it is the scheme that decides where the layer sits.
172		let layer_depth = self
173			.scheme
174			.optimal_verify_layer(indices.len(), commitment.depth);
175		self.proof_size +=
176			self.scheme
177				.proof_size(1 << commitment.depth, indices.len(), layer_depth);
178
179		// Each opened leaf also sends its own values.
180		let n_values = indices.len() * commitment.leaf_size;
181		self.proof_size += n_values * F::BYTE_SIZE;
182		Ok(vec![F::ZERO; n_values])
183	}
184
185	fn recv_committed_vector(&mut self, commitment: &Self::Commitment) -> Result<Vec<F>, Error> {
186		// The whole vector goes on the wire, so the receiver rebuilds the tree and needs no branch.
187		let len = commitment.leaf_size << commitment.depth;
188		self.proof_size += len * F::BYTE_SIZE;
189		Ok(vec![F::ZERO; len])
190	}
191}
192
193#[cfg(test)]
194mod tests {
195	use binius_field::Ghash128b as B128;
196	use binius_hash::StdHashSuite;
197
198	use super::*;
199	use crate::merkle_tree::BinaryMerkleTreeScheme;
200
201	#[test]
202	fn a_grind_is_charged_one_nonce_and_a_zero_bit_one_is_charged_nothing() {
203		// Invariant: this channel prices a protocol by running it, so what it charges for a grind
204		// has to be what the prover really writes. That is one `u64` nonce, whatever the
205		// difficulty, and nothing at all when the difficulty is zero.
206		//
207		// Fixture state: a channel over the shipped Merkle scheme, with nothing else received.
208		let scheme = BinaryMerkleTreeScheme::<B128, StdHashSuite>::new();
209		let mut channel = SizeTrackingChannel::<B128, _>::new(&scheme);
210		assert_eq!(channel.proof_size(), 0);
211
212		channel
213			.verify_grind(0)
214			.expect("a zero-bit grind cannot fail");
215		assert_eq!(channel.proof_size(), 0);
216
217		for bits in [1, 8, 32] {
218			channel.verify_grind(bits).expect("nothing here can fail");
219		}
220		assert_eq!(channel.proof_size(), 3 * size_of::<u64>());
221	}
222}