diff --git a/Cargo.toml b/Cargo.toml index b33efebf..185d3187 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ byteorder = "1.4.3" thiserror = "1.0" halo2curves = { version = "0.4.0", features = ["derive_serde"] } group = "0.13.0" +once_cell = "1.18.0" [target.'cfg(any(target_arch = "x86_64", target_arch = "aarch64"))'.dependencies] pasta-msm = { version = "0.1.4" } diff --git a/src/digest.rs b/src/digest.rs new file mode 100644 index 00000000..1ec0ad0c --- /dev/null +++ b/src/digest.rs @@ -0,0 +1,166 @@ +use bincode::Options; +use ff::PrimeField; +use serde::Serialize; +use sha3::{Digest, Sha3_256}; +use std::io; +use std::marker::PhantomData; + +use crate::constants::NUM_HASH_BITS; + +/// Trait for components with potentially discrete digests to be included in their container's digest. +pub trait Digestible { + /// Write the byte representation of Self in a byte buffer + fn write_bytes(&self, byte_sink: &mut W) -> Result<(), io::Error>; +} + +/// Marker trait to be implemented for types that implement `Digestible` and `Serialize`. +/// Their instances will be serialized to bytes then digested. +pub trait SimpleDigestible: Serialize {} + +impl Digestible for T { + fn write_bytes(&self, byte_sink: &mut W) -> Result<(), io::Error> { + let config = bincode::DefaultOptions::new() + .with_little_endian() + .with_fixint_encoding(); + // Note: bincode recursively length-prefixes every field! + config + .serialize_into(byte_sink, self) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) + } +} + +pub struct DigestComputer<'a, F: PrimeField, T> { + inner: &'a T, + _phantom: PhantomData, +} + +impl<'a, F: PrimeField, T: Digestible> DigestComputer<'a, F, T> { + fn hasher() -> Sha3_256 { + Sha3_256::new() + } + + fn map_to_field(digest: &mut [u8]) -> F { + let bv = (0..NUM_HASH_BITS).map(|i| { + let (byte_pos, bit_pos) = (i / 8, i % 8); + let bit = (digest[byte_pos] >> bit_pos) & 1; + bit == 1 + }); + + // turn the bit vector into a scalar + let mut digest = F::ZERO; + let mut coeff = F::ONE; + for bit in bv { + if bit { + digest += coeff; + } + coeff += coeff; + } + digest + } + + /// Create a new DigestComputer + pub fn new(inner: &'a T) -> Self { + DigestComputer { + inner, + _phantom: PhantomData, + } + } + + /// Compute the digest of a `Digestible` instance. + pub fn digest(&self) -> Result { + let mut hasher = Self::hasher(); + self + .inner + .write_bytes(&mut hasher) + .expect("Serialization error"); + let mut bytes: [u8; 32] = hasher.finalize().into(); + Ok(Self::map_to_field(&mut bytes)) + } +} + +#[cfg(test)] +mod tests { + use ff::Field; + use once_cell::sync::OnceCell; + use pasta_curves::pallas; + use serde::{Deserialize, Serialize}; + + use crate::traits::Group; + + use super::{DigestComputer, SimpleDigestible}; + + #[derive(Serialize, Deserialize)] + struct S { + i: usize, + #[serde(skip, default = "OnceCell::new")] + digest: OnceCell, + } + + impl SimpleDigestible for S {} + + impl S { + fn new(i: usize) -> Self { + S { + i, + digest: OnceCell::new(), + } + } + + fn digest(&self) -> G::Scalar { + self + .digest + .get_or_try_init(|| DigestComputer::new(self).digest()) + .cloned() + .unwrap() + } + } + + type G = pallas::Point; + + #[test] + fn test_digest_field_not_ingested_in_computation() { + let s1 = S::::new(42); + + // let's set up a struct with a weird digest field to make sure the digest computation does not depend of it + let oc = OnceCell::new(); + oc.set(::Scalar::ONE).unwrap(); + + let s2: S = S { i: 42, digest: oc }; + + assert_eq!( + DigestComputer::<::Scalar, _>::new(&s1) + .digest() + .unwrap(), + DigestComputer::<::Scalar, _>::new(&s2) + .digest() + .unwrap() + ); + + // note: because of the semantics of `OnceCell::get_or_try_init`, the above + // equality will not result in `s1.digest() == s2.digest` + assert_ne!( + s2.digest(), + DigestComputer::<::Scalar, _>::new(&s2) + .digest() + .unwrap() + ); + } + + #[test] + fn test_digest_impervious_to_serialization() { + let good_s = S::::new(42); + + // let's set up a struct with a weird digest field to confuse deserializers + let oc = OnceCell::new(); + oc.set(::Scalar::ONE).unwrap(); + + let bad_s: S = S { i: 42, digest: oc }; + // this justifies the adjective "bad" + assert_ne!(good_s.digest(), bad_s.digest()); + + let naughty_bytes = bincode::serialize(&bad_s).unwrap(); + + let retrieved_s: S = bincode::deserialize(&naughty_bytes).unwrap(); + assert_eq!(good_s.digest(), retrieved_s.digest()) + } +} diff --git a/src/errors.rs b/src/errors.rs index 1cfc5c0e..95385ad6 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -56,4 +56,7 @@ pub enum NovaError { /// return when error during synthesis #[error("SynthesisError")] SynthesisError, + /// returned when there is an error creating a digest + #[error("DigestError")] + DigestError, } diff --git a/src/lib.rs b/src/lib.rs index fe6821d5..e0849541 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ mod bellpepper; mod circuit; mod constants; +mod digest; mod nifs; mod r1cs; @@ -25,6 +26,8 @@ pub mod provider; pub mod spartan; pub mod traits; +use once_cell::sync::OnceCell; + use crate::bellpepper::{ r1cs::{NovaShape, NovaWitness}, shape_cs::ShapeCS, @@ -40,7 +43,8 @@ use gadgets::utils::scalar_as_base; use nifs::NIFS; use r1cs::{R1CSInstance, R1CSShape, R1CSWitness, RelaxedR1CSInstance, RelaxedR1CSWitness}; use serde::{Deserialize, Serialize}; -use sha3::{Digest, Sha3_256}; + +use crate::digest::{DigestComputer, SimpleDigestible}; use traits::{ circuit::StepCircuit, commitment::{CommitmentEngineTrait, CommitmentTrait}, @@ -70,9 +74,18 @@ where r1cs_shape_secondary: R1CSShape, augmented_circuit_params_primary: NovaAugmentedCircuitParams, augmented_circuit_params_secondary: NovaAugmentedCircuitParams, - digest: G1::Scalar, // digest of everything else with this field set to G1::Scalar::ZERO - _p_c1: PhantomData, - _p_c2: PhantomData, + #[serde(skip, default = "OnceCell::new")] + digest: OnceCell, + _p: PhantomData<(C1, C2)>, +} + +impl SimpleDigestible for PublicParams +where + G1: Group::Scalar>, + G2: Group::Scalar>, + C1: StepCircuit, + C2: StepCircuit, +{ } impl PublicParams @@ -121,7 +134,7 @@ where let _ = circuit_secondary.synthesize(&mut cs); let (r1cs_shape_secondary, ck_secondary) = cs.r1cs_shape(); - let mut pp = Self { + PublicParams { F_arity_primary, F_arity_secondary, ro_consts_primary, @@ -134,15 +147,18 @@ where r1cs_shape_secondary, augmented_circuit_params_primary, augmented_circuit_params_secondary, - digest: G1::Scalar::ZERO, - _p_c1: Default::default(), - _p_c2: Default::default(), - }; - - // set the digest in pp - pp.digest = compute_digest::>(&pp); + digest: OnceCell::new(), + _p: Default::default(), + } + } - pp + /// Retrieve the digest of the public parameters. + pub fn digest(&self) -> G1::Scalar { + self + .digest + .get_or_try_init(|| DigestComputer::new(self).digest()) + .cloned() + .expect("Failure in retrieving digest") } /// Returns the number of constraints in the primary and secondary circuits @@ -203,7 +219,7 @@ where // base case for the primary let mut cs_primary: SatisfyingAssignment = SatisfyingAssignment::new(); let inputs_primary: NovaAugmentedCircuitInputs = NovaAugmentedCircuitInputs::new( - scalar_as_base::(pp.digest), + scalar_as_base::(pp.digest()), G1::Scalar::ZERO, z0_primary, None, @@ -230,7 +246,7 @@ where // base case for the secondary let mut cs_secondary: SatisfyingAssignment = SatisfyingAssignment::new(); let inputs_secondary: NovaAugmentedCircuitInputs = NovaAugmentedCircuitInputs::new( - pp.digest, + pp.digest(), G2::Scalar::ZERO, z0_secondary, None, @@ -322,7 +338,7 @@ where let (nifs_secondary, (r_U_secondary, r_W_secondary)) = NIFS::prove( &pp.ck_secondary, &pp.ro_consts_secondary, - &scalar_as_base::(pp.digest), + &scalar_as_base::(pp.digest()), &pp.r1cs_shape_secondary, &self.r_U_secondary, &self.r_W_secondary, @@ -333,7 +349,7 @@ where let mut cs_primary: SatisfyingAssignment = SatisfyingAssignment::new(); let inputs_primary: NovaAugmentedCircuitInputs = NovaAugmentedCircuitInputs::new( - scalar_as_base::(pp.digest), + scalar_as_base::(pp.digest()), G1::Scalar::from(self.i as u64), z0_primary, Some(self.zi_primary.clone()), @@ -361,7 +377,7 @@ where let (nifs_primary, (r_U_primary, r_W_primary)) = NIFS::prove( &pp.ck_primary, &pp.ro_consts_primary, - &pp.digest, + &pp.digest(), &pp.r1cs_shape_primary, &self.r_U_primary, &self.r_W_primary, @@ -372,7 +388,7 @@ where let mut cs_secondary: SatisfyingAssignment = SatisfyingAssignment::new(); let inputs_secondary: NovaAugmentedCircuitInputs = NovaAugmentedCircuitInputs::new( - pp.digest, + pp.digest(), G2::Scalar::from(self.i as u64), z0_secondary, Some(self.zi_secondary.clone()), @@ -451,7 +467,7 @@ where pp.ro_consts_secondary.clone(), NUM_FE_WITHOUT_IO_FOR_CRHF + 2 * pp.F_arity_primary, ); - hasher.absorb(pp.digest); + hasher.absorb(pp.digest()); hasher.absorb(G1::Scalar::from(num_steps as u64)); for e in z0_primary { hasher.absorb(*e); @@ -465,7 +481,7 @@ where pp.ro_consts_primary.clone(), NUM_FE_WITHOUT_IO_FOR_CRHF + 2 * pp.F_arity_secondary, ); - hasher2.absorb(scalar_as_base::(pp.digest)); + hasher2.absorb(scalar_as_base::(pp.digest())); hasher2.absorb(G2::Scalar::from(num_steps as u64)); for e in z0_secondary { hasher2.absorb(*e); @@ -556,7 +572,7 @@ where F_arity_secondary: usize, ro_consts_primary: ROConstants, ro_consts_secondary: ROConstants, - digest: G1::Scalar, + pp_digest: G1::Scalar, vk_primary: S1::VerifierKey, vk_secondary: S2::VerifierKey, _p_c1: PhantomData, @@ -624,7 +640,7 @@ where F_arity_secondary: pp.F_arity_secondary, ro_consts_primary: pp.ro_consts_primary.clone(), ro_consts_secondary: pp.ro_consts_secondary.clone(), - digest: pp.digest, + pp_digest: pp.digest(), vk_primary, vk_secondary, _p_c1: Default::default(), @@ -644,7 +660,7 @@ where let res_secondary = NIFS::prove( &pp.ck_secondary, &pp.ro_consts_secondary, - &scalar_as_base::(pp.digest), + &scalar_as_base::(pp.digest()), &pp.r1cs_shape_secondary, &recursive_snark.r_U_secondary, &recursive_snark.r_W_secondary, @@ -718,7 +734,7 @@ where vk.ro_consts_secondary.clone(), NUM_FE_WITHOUT_IO_FOR_CRHF + 2 * vk.F_arity_primary, ); - hasher.absorb(vk.digest); + hasher.absorb(vk.pp_digest); hasher.absorb(G1::Scalar::from(num_steps as u64)); for e in z0_primary { hasher.absorb(e); @@ -732,7 +748,7 @@ where vk.ro_consts_primary.clone(), NUM_FE_WITHOUT_IO_FOR_CRHF + 2 * vk.F_arity_secondary, ); - hasher2.absorb(scalar_as_base::(vk.digest)); + hasher2.absorb(scalar_as_base::(vk.pp_digest)); hasher2.absorb(G2::Scalar::from(num_steps as u64)); for e in z0_secondary { hasher2.absorb(e); @@ -757,7 +773,7 @@ where // fold the running instance and last instance to get a folded instance let f_U_secondary = self.nifs_secondary.verify( &vk.ro_consts_secondary, - &scalar_as_base::(vk.digest), + &scalar_as_base::(vk.pp_digest), &self.r_U_secondary, &self.l_u_secondary, )?; @@ -788,33 +804,6 @@ type Commitment = <::CE as CommitmentEngineTrait>::Commitment; type CompressedCommitment = <<::CE as CommitmentEngineTrait>::Commitment as CommitmentTrait>::CompressedCommitment; type CE = ::CE; -fn compute_digest(o: &T) -> G::Scalar { - // obtain a vector of bytes representing public parameters - let bytes = bincode::serialize(o).unwrap(); - // convert pp_bytes into a short digest - let mut hasher = Sha3_256::new(); - hasher.update(&bytes); - let digest = hasher.finalize(); - - // truncate the digest to NUM_HASH_BITS bits - let bv = (0..NUM_HASH_BITS).map(|i| { - let (byte_pos, bit_pos) = (i / 8, i % 8); - let bit = (digest[byte_pos] >> bit_pos) & 1; - bit == 1 - }); - - // turn the bit vector into a scalar - let mut digest = G::Scalar::ZERO; - let mut coeff = G::Scalar::ONE; - for bit in bv { - if bit { - digest += coeff; - } - coeff += coeff; - } - digest -} - #[cfg(test)] mod tests { use crate::provider::bn256_grumpkin::{bn256, grumpkin}; @@ -898,7 +887,7 @@ mod tests { let pp = PublicParams::::setup(circuit1, circuit2); let digest_str = pp - .digest + .digest() .to_repr() .as_ref() .iter() @@ -918,13 +907,13 @@ mod tests { test_pp_digest_with::( &trivial_circuit1, &trivial_circuit2, - "39a4ea9dd384346fdeb6b5857c7be56fa035153b616d55311f3191dfbceea603", + "fe14a77d74cb8b8bb13105cea9c5b98b621b42c8d61da8f2adce8b9dd0d51b03", ); test_pp_digest_with::( &cubic_circuit1, &trivial_circuit2, - "3f7b25f589f2da5ab26254beba98faa54f6442ebf5fa5860caf7b08b576cab00", + "21ac840e52c75a62823cfdda4ca77aae2f07e4b6f5aa0eba80135492b2fbd003", ); let trivial_circuit1_grumpkin = @@ -936,12 +925,12 @@ mod tests { test_pp_digest_with::( &trivial_circuit1_grumpkin, &trivial_circuit2_grumpkin, - "967acca1d6b4731cd65d4072c12bbaca9648f24d7bcc2877aee720e4265d4302", + "0b25debdc99cef04b6d113a9a2814de89b3fad239aea90b29f2bdb27d95afa02", ); test_pp_digest_with::( &cubic_circuit1_grumpkin, &trivial_circuit2_grumpkin, - "44629f26a78bf6c4e3077f940232050d1793d304fdba5e221d0cf66f76a37903", + "0747f68f8d1c4bac4c3fb82689a1488b5835bbc97d6f6023fbe2760bb0053b00", ); let trivial_circuit1_secp = @@ -952,13 +941,13 @@ mod tests { test_pp_digest_with::( &trivial_circuit1_secp, - &trivial_circuit2_secp.clone(), - "b99760668a42354643e17b2f0a2d54f173d237eb213e7e758b20a88b4c653c01", + &trivial_circuit2_secp, + "0cf0880fa8debe42b7789474f6787062f8118ef251450dd5a7a4b5430f4bb902", ); test_pp_digest_with::( &cubic_circuit1_secp, &trivial_circuit2_secp, - "68db620e610a3cd75146a1e1bdd168f486b82c0b670277ad1e3d50441c501502", + "623a1dd99f3c906e79397f3de0dc1565b35fcb69abf2da51847bc9879a0a6000", ); } diff --git a/src/r1cs.rs b/src/r1cs.rs index cf5a770e..0e7726a2 100644 --- a/src/r1cs.rs +++ b/src/r1cs.rs @@ -2,6 +2,7 @@ #![allow(clippy::type_complexity)] use crate::{ constants::{BN_LIMB_WIDTH, BN_N_LIMBS}, + digest::{DigestComputer, SimpleDigestible}, errors::NovaError, gadgets::{ nonnative::{bignat::nat_to_limbs, util::f_to_nat}, @@ -14,6 +15,7 @@ use crate::{ }; use core::{cmp::max, marker::PhantomData}; use ff::Field; +use once_cell::sync::OnceCell; use rayon::prelude::*; use serde::{Deserialize, Serialize}; @@ -34,8 +36,12 @@ pub struct R1CSShape { pub(crate) A: Vec<(usize, usize, G::Scalar)>, pub(crate) B: Vec<(usize, usize, G::Scalar)>, pub(crate) C: Vec<(usize, usize, G::Scalar)>, + #[serde(skip, default = "OnceCell::new")] + pub(crate) digest: OnceCell, } +impl SimpleDigestible for R1CSShape {} + /// A type that holds a witness for a given R1CS instance #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct R1CSWitness { @@ -130,9 +136,19 @@ impl R1CSShape { A: A.to_owned(), B: B.to_owned(), C: C.to_owned(), + digest: OnceCell::new(), }) } + /// returnd the digest of the `R1CSShape` + pub fn digest(&self) -> G::Scalar { + self + .digest + .get_or_try_init(|| DigestComputer::new(self).digest()) + .cloned() + .expect("Failure retrieving digest") + } + // Checks regularity conditions on the R1CSShape, required in Spartan-class SNARKs // Panics if num_cons, num_vars, or num_io are not powers of two, or if num_io > num_vars #[inline] @@ -326,6 +342,7 @@ impl R1CSShape { A: self.A.clone(), B: self.B.clone(), C: self.C.clone(), + digest: OnceCell::new(), }; } @@ -359,6 +376,7 @@ impl R1CSShape { A: A_padded, B: B_padded, C: C_padded, + digest: OnceCell::new(), } } } diff --git a/src/spartan/ppsnark.rs b/src/spartan/ppsnark.rs index 9936b6ad..45314d50 100644 --- a/src/spartan/ppsnark.rs +++ b/src/spartan/ppsnark.rs @@ -3,7 +3,7 @@ //! The verifier in this preprocessing SNARK maintains a commitment to R1CS matrices. This is beneficial when using a //! polynomial commitment scheme in which the verifier's costs is succinct. use crate::{ - compute_digest, + digest::{DigestComputer, SimpleDigestible}, errors::NovaError, r1cs::{R1CSShape, RelaxedR1CSInstance, RelaxedR1CSWitness}, spartan::{ @@ -27,6 +27,7 @@ use crate::{ }; use core::{cmp::max, marker::PhantomData}; use ff::{Field, PrimeField}; +use once_cell::sync::OnceCell; use rayon::prelude::*; use serde::{Deserialize, Serialize}; @@ -670,9 +671,12 @@ pub struct VerifierKey> { num_vars: usize, vk_ee: EE::VerifierKey, S_comm: R1CSShapeSparkCommitment, - digest: G::Scalar, + #[serde(skip, default = "OnceCell::new")] + digest: OnceCell, } +impl> SimpleDigestible for VerifierKey {} + /// A succinct proof of knowledge of a witness to a relaxed R1CS instance /// The proof is produced using Spartan's combination of the sum-check and /// the commitment to a vector viewed as a polynomial commitment @@ -839,6 +843,35 @@ impl> RelaxedR1CSSNARK { } } +impl> VerifierKey { + fn new( + num_cons: usize, + num_vars: usize, + S_comm: R1CSShapeSparkCommitment, + vk_ee: EE::VerifierKey, + ) -> Self { + VerifierKey { + num_cons, + num_vars, + S_comm, + vk_ee, + digest: Default::default(), + } + } + + /// Returns the digest of the verifier's key + pub fn digest(&self) -> G::Scalar { + self + .digest + .get_or_try_init(|| { + let dc = DigestComputer::new(self); + dc.digest() + }) + .cloned() + .expect("Failure to retrieve digest!") + } +} + impl> RelaxedR1CSSNARKTrait for RelaxedR1CSSNARK { type ProverKey = ProverKey; type VerifierKey = VerifierKey; @@ -855,24 +888,14 @@ impl> RelaxedR1CSSNARKTrait for Relaxe let S_repr = R1CSShapeSparkRepr::new(&S); let S_comm = S_repr.commit(ck); - let vk = { - let mut vk = VerifierKey { - num_cons: S.num_cons, - num_vars: S.num_vars, - S_comm: S_comm.clone(), - vk_ee, - digest: G::Scalar::ZERO, - }; - vk.digest = compute_digest::>(&vk); - vk - }; + let vk = VerifierKey::new(S.num_cons, S.num_vars, S_comm.clone(), vk_ee); let pk = ProverKey { pk_ee, S, S_repr, S_comm, - vk_digest: vk.digest, + vk_digest: vk.digest(), }; Ok((pk, vk)) @@ -1494,7 +1517,7 @@ impl> RelaxedR1CSSNARKTrait for Relaxe let mut u_vec: Vec> = Vec::new(); // append the verifier key (including commitment to R1CS matrices) and the RelaxedR1CSInstance to the transcript - transcript.absorb(b"vk", &vk.digest); + transcript.absorb(b"vk", &vk.digest()); transcript.absorb(b"U", U); let comm_Az = Commitment::::decompress(&self.comm_Az)?; diff --git a/src/spartan/snark.rs b/src/spartan/snark.rs index 9fc3b006..55ec3a91 100644 --- a/src/spartan/snark.rs +++ b/src/spartan/snark.rs @@ -5,7 +5,7 @@ //! an IPA-based polynomial commitment scheme. use crate::{ - compute_digest, + digest::{DigestComputer, SimpleDigestible}, errors::NovaError, r1cs::{R1CSShape, RelaxedR1CSInstance, RelaxedR1CSWitness}, spartan::{ @@ -20,6 +20,7 @@ use crate::{ Commitment, CommitmentKey, }; use ff::Field; +use once_cell::sync::OnceCell; use rayon::prelude::*; use serde::{Deserialize, Serialize}; @@ -39,7 +40,32 @@ pub struct ProverKey> { pub struct VerifierKey> { vk_ee: EE::VerifierKey, S: R1CSShape, - digest: G::Scalar, + #[serde(skip, default = "OnceCell::new")] + digest: OnceCell, +} + +impl> SimpleDigestible for VerifierKey {} + +impl> VerifierKey { + fn new(shape: R1CSShape, vk_ee: EE::VerifierKey) -> Self { + VerifierKey { + vk_ee, + S: shape, + digest: OnceCell::new(), + } + } + + /// Returns the digest of the verifier's key. + pub fn digest(&self) -> G::Scalar { + self + .digest + .get_or_try_init(|| { + let dc = DigestComputer::::new(self); + dc.digest() + }) + .cloned() + .expect("Failure to retrieve digest!") + } } /// A succinct proof of knowledge of a witness to a relaxed R1CS instance @@ -70,20 +96,12 @@ impl> RelaxedR1CSSNARKTrait for Relaxe let S = S.pad(); - let vk = { - let mut vk = VerifierKey { - vk_ee, - S: S.clone(), - digest: G::Scalar::ZERO, - }; - vk.digest = compute_digest::>(&vk); - vk - }; + let vk: VerifierKey = VerifierKey::new(S.clone(), vk_ee); let pk = ProverKey { pk_ee, S, - vk_digest: vk.digest, + vk_digest: vk.digest(), }; Ok((pk, vk)) @@ -344,7 +362,7 @@ impl> RelaxedR1CSSNARKTrait for Relaxe let mut transcript = G::TE::new(b"RelaxedR1CSSNARK"); // append the digest of R1CS matrices and the RelaxedR1CSInstance to the transcript - transcript.absorb(b"vk", &vk.digest); + transcript.absorb(b"vk", &vk.digest()); transcript.absorb(b"U", U); let (num_rounds_x, num_rounds_y) = (