diff --git a/proofs/CHANGELOG.md b/proofs/CHANGELOG.md index fb97feb70..78d109f7c 100644 --- a/proofs/CHANGELOG.md +++ b/proofs/CHANGELOG.md @@ -25,7 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Blind logup multiplicities polynomial on non-usable rows for ZK [#312](https://github.com/midnightntwrk/midnight-zk/pull/312) ### Changed -* BREAKING: `halo2_solidity_verifier` decodes accumulator points with the dedicated foreign point `is_id` word: each point now occupies 5 public input words (12 for the fully collapsed accumulator, 10 for the Moonlight point pair), the flag must be 0 or 1, and x/y are not decoded for identity points; verifiers rendered before this change reject the new layout +* BREAKING: `halo2_solidity_verifier` decodes accumulator points with the dedicated foreign point `is_id` word: each point now occupies 5 public input words (12 for the fully collapsed accumulator), the flag must be 0 or 1, and x/y are not decoded for identity points; verifiers rendered before this change reject the new layout +* BREAKING: `halo2_solidity_verifier` `AccumulatorEncoding::point_pair` matches the Moonlight wrap circuit, which constrains both accumulator points to be non-identity: each point is only its x/y coordinate words (`POINT_PAIR_PUBLIC_INPUT_WORDS` is 8), there is no identity flag word, and a point that decodes to the EIP-2537 identity is rejected; point-pair verifiers rendered before this change reject the new layout * Simplify `CommitmentReference` by removing unused `Chopped` variant [#314](https://github.com/midnightntwrk/midnight-zk/pull/314) * Split linearization polynomial into non-constant and constant parts, removing the generator point from the MSM [#313](https://github.com/midnightntwrk/midnight-zk/pull/313) * Remove unnecessary polynomial padding in KZG multi-open [#276](https://github.com/midnightntwrk/midnight-zk/pull/276) diff --git a/proofs/solidity-verifier/deployments/sepolia/moonlight-wrap/README.md b/proofs/solidity-verifier/deployments/sepolia/moonlight-wrap/README.md index 0a2c41b79..d0c14c340 100644 --- a/proofs/solidity-verifier/deployments/sepolia/moonlight-wrap/README.md +++ b/proofs/solidity-verifier/deployments/sepolia/moonlight-wrap/README.md @@ -4,6 +4,16 @@ This directory contains the generated Moonlight wrap verifier contracts that were deployed to Sepolia, plus the exact runtime bytecode fetched back from the chain. +> **Status: historical demo deployment, not regularly updated.** These +> contracts were deployed once, on 2026-05-19, and have not been redeployed +> since. They were rendered from a synthetic Moonlight wrap benchmark (dummy +> fold-leaf proofs, a `k = 20` wrap circuit, and a seeded `unsafe_setup` SRS +> whose secret is public, so proofs can be forged against them). They predate +> later changes to the Moonlight wrap circuit and to the generator's +> accumulator public-input layout, so they neither match current generator +> output nor accept current Moonlight wrap proofs. The files below are kept as +> an exact record of what is on-chain. + ## Addresses | Contract | Address | diff --git a/proofs/solidity-verifier/docs/architecture/LOWERING_ARCHITECTURE_SPEC.md b/proofs/solidity-verifier/docs/architecture/LOWERING_ARCHITECTURE_SPEC.md index 2b1fea580..eeeacf605 100644 --- a/proofs/solidity-verifier/docs/architecture/LOWERING_ARCHITECTURE_SPEC.md +++ b/proofs/solidity-verifier/docs/architecture/LOWERING_ARCHITECTURE_SPEC.md @@ -453,7 +453,8 @@ Two public-input accumulator layouts are supported: encoded as `lhs point, lhs scalar, rhs point, rhs scalar`, with an optional fixed-base scalar tail. - `AccumulatorEncoding::point_pair(...)`: already-collapsed Moonlight wrap - input, encoded as `lhs point, rhs point`; the generated verifier treats both + input, encoded as `lhs.x, lhs.y, rhs.x, rhs.y` with no identity flag words; + both points must be non-identity. The generated verifier treats both carried scalars as one and rejects fixed-base scalar tails for this layout. The accumulator layout is intentionally explicit because public input packing is diff --git a/proofs/solidity-verifier/docs/reference/HALO2_MIDNIGHT_VERIFIER_SPEC.md b/proofs/solidity-verifier/docs/reference/HALO2_MIDNIGHT_VERIFIER_SPEC.md index 079f252f5..fe4e1610b 100644 --- a/proofs/solidity-verifier/docs/reference/HALO2_MIDNIGHT_VERIFIER_SPEC.md +++ b/proofs/solidity-verifier/docs/reference/HALO2_MIDNIGHT_VERIFIER_SPEC.md @@ -1313,8 +1313,11 @@ let generator = SolidityGenerator::new( ); ``` -The point-pair layout is `lhs point, rhs point`; the generated verifier uses -implicit unit scalars for both sides and rejects fixed-base scalar tails. +The point-pair layout is `lhs.x, lhs.y, rhs.x, rhs.y`; the generated verifier +uses implicit unit scalars for both sides and rejects fixed-base scalar tails. +The Moonlight wrap circuit constrains both points to be non-identity, so +neither point carries an identity flag word and the verifier rejects any point +that decodes to the identity. Use `SolidityGenerator::try_new` when the public-input layout comes from caller-controlled metadata and should return a typed `GeneratorError` instead @@ -1404,8 +1407,20 @@ rhs_is_id: 1 word rhs_scalar: 1 word ``` -for 12 words in total. The Moonlight point-pair layout is the same without the -two scalar words, for 10 words in total. +for 12 words in total. The Moonlight point-pair layout has neither scalar words +nor identity flag words, because the wrap circuit constrains both points to be +non-identity: + +```text +point_words = 2 * coord_words = 4 + +lhs_x: 2 words +lhs_y: 2 words +rhs_x: 2 words +rhs_y: 2 words +``` + +for 8 words in total. If a fixed-base scalar tail exists, it is consumed as RHS MSM scalars for: @@ -1413,16 +1428,20 @@ If a fixed-base scalar tail exists, it is consumed as RHS MSM scalars for: - fixed commitments; - permutation commitments. +The tail must be empty or contain exactly one scalar for each of these bases +(`1 + #fixed + #permutation` commitments of the verified VK); the generator +rejects any other length with `UnsupportedAccumulatorEncoding`. + The tail bases are sorted lexicographically by their generated names to match Midfall's `BTreeMap` order. ### 13.2 Coordinate Reconstruction Coordinates are exposed by Midnight circuits as limbs of `coord - 1`, packed -four limbs per native field element. Each point's coordinates are followed by -a dedicated identity flag word, which the circuit constrains to be `0` or `1`. -When the flag is set the circuit leaves x and y unconstrained, so their limbs -carry no meaning. +four limbs per native field element. In the point-and-scalar layout, each +point's coordinates are followed by a dedicated identity flag word, which the +circuit constrains to be `0` or `1`. When the flag is set the circuit leaves x +and y unconstrained, so their limbs carry no meaning. The verifier: @@ -1438,6 +1457,10 @@ The verifier: 8. Rejects a decoded non-identity point whose coordinates are both zero. 9. Stores the point as a padded G1 tuple. +The point-pair layout has no identity flag word, so the verifier skips steps 1 +and 2 and always runs steps 3 to 9 for both points. A point whose coordinates +decode to `(0, 0)`, the EIP-2537 identity encoding, is therefore rejected. + ### 13.3 Accumulator Pairing Batch After `PAIRING_LHS` and `PAIRING_RHS` are fixed, derive: diff --git a/proofs/solidity-verifier/docs/reference/TEAM_DEMO_SETUP.md b/proofs/solidity-verifier/docs/reference/TEAM_DEMO_SETUP.md index e0a197899..f15192763 100644 --- a/proofs/solidity-verifier/docs/reference/TEAM_DEMO_SETUP.md +++ b/proofs/solidity-verifier/docs/reference/TEAM_DEMO_SETUP.md @@ -178,6 +178,18 @@ The Moonlight demo proves the final wrap recursion proof from Solidity verifier with this repository, deploys it into local Prague `revm`, and verifies the Moonlight proof on-chain. +> **Known issue: the Moonlight part of this demo is currently broken.** The +> default Moonlight branch, `codex/wrap-bench-cherry-picks`, was last updated in +> May 2026. It predates two public-input layout changes: Midfall's dedicated +> foreign-point identity-flag word +> ([EYBlockchain/midfall#15](https://github.com/EYBlockchain/midfall/pull/15)) +> and Moonlight's flagless, non-identity wrap accumulator +> ([EYBlockchain/Moonlight#91](https://github.com/EYBlockchain/Moonlight/pull/91)). +> Against this checkout its wrap bench fails before reaching verifier +> generation. The demo needs a Moonlight ref with those benchmark commits +> rebased onto current `master`; until then, run +> `scripts/run_team_demo.sh --skip-moonlight`. + Clone Moonlight next to this verifier repo: ```bash @@ -254,8 +266,9 @@ The run is much heavier than the IVC bench. On the demo machine it took about `CircuitTranscript` and with the final outer proof layout set to no fewer-point-sets so it matches the generated Solidity verifier. The Solidity path also enables the public accumulator check. Moonlight exposes -its final wrap accumulator as an already-collapsed `(lhs, rhs)` point pair at -instance offset `11`, so the verifier uses +its final wrap accumulator as an already-collapsed, non-identity `(lhs, rhs)` +point pair at instance offset `11`, as `lhs.x, lhs.y, rhs.x, rhs.y` with no +identity flag words. The verifier therefore uses `AccumulatorEncoding::point_pair(offset, 7, 56)` rather than the Midfall IVC `point, scalar, point, scalar` layout. diff --git a/proofs/solidity-verifier/scripts/run_team_demo.sh b/proofs/solidity-verifier/scripts/run_team_demo.sh index 9e3e44f7b..6fed16d32 100755 --- a/proofs/solidity-verifier/scripts/run_team_demo.sh +++ b/proofs/solidity-verifier/scripts/run_team_demo.sh @@ -18,6 +18,10 @@ SRS_DIR="${SRS_DIR:-"$ROOT_DIR/.srs"}" LOG_DIR="${LOG_DIR:-"$ROOT_DIR/target/team-demo-logs"}" MOONLIGHT_DIR="${MOONLIGHT_DIR:-"$DEFAULT_MOONLIGHT_DIR"}" +# Known stale: this branch predates midfall#15 (identity-flag words) and +# Moonlight#91 (flagless wrap accumulator), so the Moonlight step fails against +# current Midfall. Point it at a Moonlight ref rebased onto master, or pass +# --skip-moonlight. See docs/reference/TEAM_DEMO_SETUP.md. MOONLIGHT_BRANCH="${MOONLIGHT_BRANCH:-codex/wrap-bench-cherry-picks}" MOONLIGHT_URL="${MOONLIGHT_URL:-git@github.com:EYBlockchain/Moonlight.git}" MOONLIGHT_HTTPS_URL="${MOONLIGHT_HTTPS_URL:-https://github.com/EYBlockchain/Moonlight.git}" @@ -82,6 +86,8 @@ Options: --moonlight-dir DIR Moonlight checkout, relative to this repo unless absolute. Defaults to a Moonlight checkout next to the Midfall repo. --moonlight-branch NAME Moonlight branch. Defaults to codex/wrap-bench-cherry-picks. + That default is currently stale (see TEAM_DEMO_SETUP.md); + use --skip-moonlight until it is updated. --moonlight-url URL SSH clone URL for Moonlight. --https Clone Moonlight with the HTTPS URL. --update-moonlight Fetch and switch Moonlight to the requested branch. diff --git a/proofs/solidity-verifier/src/api.rs b/proofs/solidity-verifier/src/api.rs index c0a29bb5c..36f226bd2 100644 --- a/proofs/solidity-verifier/src/api.rs +++ b/proofs/solidity-verifier/src/api.rs @@ -32,6 +32,15 @@ use crate::lowering::layout; /// followed by one `0`/`1` identity-flag word. Any public-input words after /// the fixed accumulator payload are interpreted as the optional RHS /// fixed-base scalar tail for partially collapsed accumulators. +/// +/// [`Self::point_pair`] selects the Moonlight wrap layout instead: +/// +/// ```text +/// lhs point coordinates, rhs point coordinates +/// ``` +/// +/// with the same coordinate encoding but no identity-flag words, no carried +/// scalars, and no fixed-base scalar tail. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AccumulatorEncoding { /// Offset of accumulator limbs in instances. @@ -50,9 +59,11 @@ pub enum AccumulatorEncodingKind { /// `AssignedAccumulator::as_public_input`: lhs point, lhs scalar, rhs /// point, rhs scalar, followed by an optional fixed-base scalar tail. PointAndScalar, - /// Already collapsed point-pair layout: lhs point, rhs point. The generated - /// verifier treats both carried scalars as one and does not accept a - /// fixed-base scalar tail. + /// Already collapsed Moonlight wrap layout: lhs `(x, y)`, rhs `(x, y)`. + /// The circuit constrains both points to be non-identity, so neither point + /// carries an identity-flag word and the generated verifier rejects any + /// point that decodes to the identity. Both carried scalars are implicitly + /// one and a fixed-base scalar tail is not accepted. PointPair, } @@ -75,9 +86,9 @@ impl AccumulatorEncoding { pub const SUPPORTED_NUM_LIMB_BITS: usize = layout::accumulator::LIMB_BITS; /// Public-input words required by the fully collapsed accumulator form. pub const FULLY_COLLAPSED_PUBLIC_INPUT_WORDS: usize = 12; - /// Public-input words required by an already collapsed `(lhs, rhs)` point - /// pair. - pub const POINT_PAIR_PUBLIC_INPUT_WORDS: usize = 10; + /// Public-input words required by an already collapsed, non-identity + /// `(lhs, rhs)` point pair. + pub const POINT_PAIR_PUBLIC_INPUT_WORDS: usize = 8; /// Return a new `AccumulatorEncoding`. pub fn new(offset: usize, num_limbs: usize, num_limb_bits: usize) -> Self { @@ -89,7 +100,8 @@ impl AccumulatorEncoding { } } - /// Return a point-pair accumulator encoding with implicit unit scalars. + /// Return a point-pair accumulator encoding with implicit unit scalars and + /// no identity flags; both points must be non-identity. pub fn point_pair(offset: usize, num_limbs: usize, num_limb_bits: usize) -> Self { Self { offset, @@ -105,10 +117,15 @@ impl AccumulatorEncoding { self.num_limbs.div_ceil(limbs_per_instance) } - /// Number of public-input words for one G1 point: x and y coordinates - /// followed by the identity flag. + /// Number of public-input words for one G1 point: x and y coordinates, + /// followed by the identity flag when the layout carries one. pub(crate) fn point_words(self) -> usize { - 2 * self.coordinate_words() + layout::accumulator::IDENTITY_FLAG_WORDS + 2 * self.coordinate_words() + + if self.has_identity_flags() { + layout::accumulator::IDENTITY_FLAG_WORDS + } else { + 0 + } } /// Number of public-input words for the lhs and rhs G1 points. @@ -121,6 +138,12 @@ impl AccumulatorEncoding { matches!(self.kind, AccumulatorEncodingKind::PointAndScalar) } + /// Whether each public-input point is followed by an identity-flag word. + /// Point-pair accumulators are non-identity and omit the flag. + pub(crate) fn has_identity_flags(self) -> bool { + matches!(self.kind, AccumulatorEncodingKind::PointAndScalar) + } + /// Number of public-input words occupied by the fixed accumulator payload. fn fixed_payload_words(self) -> usize { self.point_pair_words() @@ -186,6 +209,32 @@ impl AccumulatorEncoding { } Ok(tail) } + + /// Validates this encoding against `num_instances` and the fixed bases of + /// the verified VK, returning the fixed-base scalar tail length. + /// + /// `num_vk_fixed_bases` is `1 + #fixed + #permutation` commitments: `-G` + /// plus every commitment in `fixed_bases(vk)`. A non-empty tail must hold + /// exactly one RHS scalar per base, which lowering maps onto them in + /// `BTreeMap` name order. + pub(crate) fn validate_for_vk( + self, + num_instances: usize, + num_vk_fixed_bases: usize, + ) -> Result { + let tail = self.fixed_scalar_count(num_instances)?; + if tail != 0 && tail != num_vk_fixed_bases { + return Err(GeneratorError::UnsupportedAccumulatorEncoding { + offset: self.offset, + num_limbs: self.num_limbs, + num_limb_bits: self.num_limb_bits, + num_instances, + reason: "fixed-base scalar tail must have one scalar for -G and each fixed and \ + permutation commitment", + }); + } + Ok(tail) + } } /// Stable diagnostic view of the identities folded into the quotient numerator. diff --git a/proofs/solidity-verifier/src/builder/api.rs b/proofs/solidity-verifier/src/builder/api.rs index 81adcb909..bdc689af1 100644 --- a/proofs/solidity-verifier/src/builder/api.rs +++ b/proofs/solidity-verifier/src/builder/api.rs @@ -47,7 +47,12 @@ impl<'a> SolidityGenerator<'a> { config.num_committed_instances, )?; if let Some(acc_encoding) = config.accumulator { - acc_encoding.validate_for_num_instances(config.num_instances)?; + // Rejects trailing words for point pairs and fixed-base scalar + // tails that don't match `vk`, which later lowering assumes were + // validated here. + let num_vk_fixed_bases = + 1 + vk.fixed_commitments().len() + vk.permutation().commitments().len(); + acc_encoding.validate_for_vk(config.num_instances, num_vk_fixed_bases)?; } if let Some((column, rotation)) = vk .cs() diff --git a/proofs/solidity-verifier/src/lowering/artifacts.rs b/proofs/solidity-verifier/src/lowering/artifacts.rs index 4dbf611ed..d173bdac9 100644 --- a/proofs/solidity-verifier/src/lowering/artifacts.rs +++ b/proofs/solidity-verifier/src/lowering/artifacts.rs @@ -192,6 +192,7 @@ impl<'params, 'meta> VerifierBuildInputs<'params, 'meta> { expected_num_acc_limbs, expected_num_acc_limb_bits, expected_acc_has_carried_scalars, + expected_acc_non_identity_points, ) = self .acc_encoding .map(|acc_encoding| { @@ -201,9 +202,10 @@ impl<'params, 'meta> VerifierBuildInputs<'params, 'meta> { acc_encoding.num_limbs, acc_encoding.num_limb_bits, acc_encoding.has_carried_scalars(), + !acc_encoding.has_identity_flags(), ) }) - .unwrap_or((false, 0, 0, 0, false)); + .unwrap_or((false, 0, 0, 0, false, false)); let verifier = Halo2Verifier { template_constants: Default::default(), @@ -273,6 +275,7 @@ impl<'params, 'meta> VerifierBuildInputs<'params, 'meta> { expected_num_acc_limbs, expected_num_acc_limb_bits, expected_acc_has_carried_scalars, + expected_acc_non_identity_points, acc_fixed_bases, }; verifier diff --git a/proofs/solidity-verifier/src/lowering/render/models.rs b/proofs/solidity-verifier/src/lowering/render/models.rs index 04295e0b2..7d6e8661b 100644 --- a/proofs/solidity-verifier/src/lowering/render/models.rs +++ b/proofs/solidity-verifier/src/lowering/render/models.rs @@ -525,7 +525,22 @@ pub(crate) struct Halo2Verifier { /// Whether the public accumulator payload carries explicit LHS/RHS scalar /// words. Moonlight wrap proofs expose an already collapsed point pair and /// use implicit unit scalars instead. + /// + /// Together with `expected_acc_non_identity_points`, this is derived from + /// `AccumulatorEncodingKind`, so only two combinations are reachable: + /// carried scalars with identity flags (`PointAndScalar`), or neither + /// (`PointPair`). This is intentional, and the accumulator template relies + /// on it. pub(crate) expected_acc_has_carried_scalars: bool, + /// Whether the public accumulator points are constrained to be + /// non-identity and therefore omit the trailing `0`/`1` identity-flag word. + /// This holds for Moonlight wrap point pairs, which expose only `(x, y)` + /// coordinate words; the point-and-scalar layout keeps the flag. + /// + /// This is set exactly when `expected_acc_has_carried_scalars` is not; see + /// that field. It is `false` without an accumulator, so those verifiers + /// render the default flagged decoding helpers. + pub(crate) expected_acc_non_identity_points: bool, /// Fixed bases serialized by `AssignedAccumulator::as_public_input` /// for the RHS accumulator MSM, in the exact lexicographic /// `BTreeMap` order used by midnight-circuits. This is the public @@ -1146,6 +1161,7 @@ mod tests { expected_num_acc_limbs: 0, expected_num_acc_limb_bits: 0, expected_acc_has_carried_scalars: false, + expected_acc_non_identity_points: false, acc_fixed_bases: vec![], } } diff --git a/proofs/solidity-verifier/src/lowering/tests.rs b/proofs/solidity-verifier/src/lowering/tests.rs index 99b9bb314..d12e10bd5 100644 --- a/proofs/solidity-verifier/src/lowering/tests.rs +++ b/proofs/solidity-verifier/src/lowering/tests.rs @@ -1465,10 +1465,10 @@ fn accumulator_decoder_reads_trailing_identity_flag_word() { // x/y must only be decoded for non-identity points: the circuit leaves // them unconstrained when the flag is set. let load_point = verifier_template - .split("function load_acc_point(") + .split("function load_acc_point(dst, src, bits, n, base) -> ok, is_id {") .nth(1) .and_then(|body| body.split("function validate_public_accumulator").next()) - .expect("load_acc_point should be rendered before validate_public_accumulator"); + .expect("flagged load_acc_point should be rendered before validate_public_accumulator"); let (id_branch, non_id_branch) = load_point.split_once("if iszero(is_id) {").expect("non-identity branch"); assert!( @@ -1537,10 +1537,229 @@ fn accumulator_word_counts_match_circuit_encoding() { assert_eq!(public[5], Fq::from(3)); assert_eq!(&public[6..11], generator_words.as_slice()); assert_eq!(public[11], Fq::from(5)); +} + +/// Compare the point-pair layout with the Moonlight wrap circuit encoding: +/// each non-identity point exposes only its x and y coordinates through +/// `AssignedField::as_public_input`, i.e. the foreign point encoding without +/// its trailing identity-flag word. +#[test] +fn point_pair_word_counts_match_coordinate_encoding() { + use group::Group; + use midnight_circuits::{ + ecc::curves::CircuitCurve, + types::{AssignedField, AssignedForeignPoint, Instantiable}, + }; + use midnight_curves::{Fp, G1Projective}; + + let encoding = AccumulatorEncoding::point_pair(0, 7, 56); + let coordinate_words = |point: &G1Projective| { + let (x, y) = point.coordinates().expect("non-identity point"); + [ + AssignedField::::as_public_input(&x), + AssignedField::::as_public_input(&y), + ] + .concat() + }; + + let lhs = G1Projective::generator(); + let rhs = lhs.double(); + for point in [lhs, rhs] { + let words = coordinate_words(&point); + assert_eq!(words.len(), encoding.point_words()); + let flagged = + AssignedForeignPoint::::as_public_input(&point); + assert_eq!(flagged.split_last(), Some((&Fq::ZERO, words.as_slice()))); + } + + let public = [coordinate_words(&lhs), coordinate_words(&rhs)].concat(); assert_eq!( - 2 * words_per_point(&G1Projective::generator()), + public.len(), AccumulatorEncoding::POINT_PAIR_PUBLIC_INPUT_WORDS ); + assert_eq!( + encoding.fully_collapsed_public_input_words(), + Ok(public.len()) + ); +} + +/// `try_new` must reject point-pair layouts with the wrong word count, and in +/// particular the old flagged 10-word layout, with a typed error rather than +/// accepting it and panicking during rendering. +#[test] +fn try_new_rejects_flagged_point_pair_layout() { + let (params, vk) = lowering_plan_test_vk(); + let config = |num_instances| { + GeneratorConfig::new(num_instances, 1) + .with_accumulator(AccumulatorEncoding::point_pair(11, 7, 56)) + }; + + // Current Moonlight wrap shape: 11 state words + 8 accumulator words. + assert!(SolidityGenerator::try_new(¶ms, &vk, config(19)).is_ok()); + + // Too short for the 8-word point pair. + assert!(matches!( + SolidityGenerator::try_new(¶ms, &vk, config(18)), + Err(GeneratorError::UnsupportedAccumulatorEncoding { + reason: "accumulator public-input tail exceeds num_instances", + .. + }) + )); + + // Old flagged shape: 11 + 10 = 21 leaves two trailing words. + assert!(matches!( + SolidityGenerator::try_new(¶ms, &vk, config(21)), + Err(GeneratorError::UnsupportedAccumulatorEncoding { + reason: "point-pair accumulator encoding does not support a fixed-base scalar tail", + .. + }) + )); +} + +/// A non-empty fixed-base scalar tail must hold exactly one scalar for `-G` +/// and each fixed and permutation commitment. Shorter tails used to panic in +/// lowering (or map scalars onto a prefix of the fixed commitments), and longer +/// ones read base pointers past the fixed-commitment region. +#[test] +fn fixed_scalar_tail_must_match_vk_fixed_bases() { + // `-G` + 3 fixed + 2 permutation commitments. + let num_vk_fixed_bases = 1 + 3 + 2; + let encoding = AccumulatorEncoding::new(4, 7, 56); + let payload_end = 4 + AccumulatorEncoding::FULLY_COLLAPSED_PUBLIC_INPUT_WORDS; + + assert_eq!( + encoding.validate_for_vk(payload_end, num_vk_fixed_bases), + Ok(0) + ); + assert_eq!( + encoding.validate_for_vk(payload_end + num_vk_fixed_bases, num_vk_fixed_bases), + Ok(num_vk_fixed_bases) + ); + // Only `-G`, within the permutation count, missing a fixed commitment, and + // one past the full tail. + for tail in [1, 2, num_vk_fixed_bases - 1, num_vk_fixed_bases + 1] { + assert!( + matches!( + encoding.validate_for_vk(payload_end + tail, num_vk_fixed_bases), + Err(GeneratorError::UnsupportedAccumulatorEncoding { + reason: "fixed-base scalar tail must have one scalar for -G and each fixed \ + and permutation commitment", + .. + }) + ), + "tail of {tail} scalars should be rejected" + ); + } + + // Point pairs still reject any tail before the VK count is considered. + let point_pair = AccumulatorEncoding::point_pair(4, 7, 56); + let point_pair_end = 4 + AccumulatorEncoding::POINT_PAIR_PUBLIC_INPUT_WORDS; + assert_eq!( + point_pair.validate_for_vk(point_pair_end, num_vk_fixed_bases), + Ok(0) + ); + assert!(matches!( + point_pair.validate_for_vk(point_pair_end + num_vk_fixed_bases, num_vk_fixed_bases), + Err(GeneratorError::UnsupportedAccumulatorEncoding { + reason: "point-pair accumulator encoding does not support a fixed-base scalar tail", + .. + }) + )); +} + +/// `try_new` sizes the fixed-base scalar tail from the verified VK, and a +/// verifier with the full tail renders. +#[test] +fn try_new_checks_fixed_scalar_tail_against_vk() { + let (params, vk) = lowering_plan_test_vk(); + let num_vk_fixed_bases = + 1 + vk.fixed_commitments().len() + vk.permutation().commitments().len(); + assert!( + num_vk_fixed_bases >= 2, + "test VK should have a fixed commitment" + ); + let config = |tail| { + GeneratorConfig::new( + AccumulatorEncoding::FULLY_COLLAPSED_PUBLIC_INPUT_WORDS + tail, + 1, + ) + .with_accumulator(AccumulatorEncoding::new(0, 7, 56)) + }; + + assert!(SolidityGenerator::try_new(¶ms, &vk, config(0)).is_ok()); + SolidityGenerator::try_new(¶ms, &vk, config(num_vk_fixed_bases)) + .expect("full fixed-base scalar tail should be accepted") + .render(crate::RenderOptions::default()) + .expect("full fixed-base scalar tail should render"); + for tail in [num_vk_fixed_bases - 1, num_vk_fixed_bases + 1] { + assert!( + matches!( + SolidityGenerator::try_new(¶ms, &vk, config(tail)), + Err(GeneratorError::UnsupportedAccumulatorEncoding { .. }) + ), + "tail of {tail} scalars should be rejected" + ); + } +} + +/// Render a point-pair verifier and check that its decoder never reads an +/// identity flag, always decodes both coordinates, and rejects the EIP-2537 +/// infinity encoding. +#[test] +fn point_pair_verifier_decodes_non_identity_coordinates_only() { + let (params, vk) = lowering_plan_test_vk(); + let render = |config| { + SolidityGenerator::new(¶ms, &vk, config) + .render(crate::RenderOptions::default()) + .expect("render accumulator verifier") + .verifier + }; + let point_pair = render( + GeneratorConfig::new(11, 1).with_accumulator(AccumulatorEncoding::point_pair(3, 7, 56)), + ); + let point_and_scalar = + render(GeneratorConfig::new(12, 1).with_accumulator(AccumulatorEncoding::new(0, 7, 56))); + + assert_eq!(point_pair.matches("function load_acc_point(").count(), 1); + let load_point = point_pair + .split("function load_acc_point(dst, src, bits, n, base) -> ok {") + .nth(1) + .and_then(|body| body.split("function validate_public_accumulator").next()) + .expect("point-pair load_acc_point should return only ok"); + assert_eq!(load_point.matches("load_acc_coord(").count(), 2); + assert!(load_point.contains("ok := and(ok, iszero(decoded_zero))")); + + let validate = point_pair + .split("function validate_public_accumulator") + .nth(1) + .and_then(|body| body.split("// The caller checks `out`").next()) + .expect("point-pair verifier should validate its accumulator"); + for required in [ + "let point_words := mul(2, coord_words)", + "let lhs_ok := load_acc_point(", + "let rhs_ok := load_acc_point(", + ] { + assert!( + validate.contains(required), + "point-pair accumulator layout: {required}" + ); + } + let code_only = |source: &str| { + source + .lines() + .map(|line| line.split("//").next().unwrap_or_default()) + .collect::>() + .join("\n") + }; + for forbidden in ["flag", "is_id"] { + assert!( + !code_only(load_point).contains(forbidden) && !code_only(validate).contains(forbidden), + "point-pair accumulator decoding must not reference `{forbidden}`" + ); + } + + assert!(point_and_scalar.contains("is_id := eq(flag, 1)")); + assert!(point_and_scalar.contains("pop(lhs_is_id)")); } #[test] @@ -1559,10 +1778,18 @@ fn accumulator_encoding_validation_rejects_dead_configs() { point_pair.fully_collapsed_public_input_words(), Ok(AccumulatorEncoding::POINT_PAIR_PUBLIC_INPUT_WORDS) ); - assert!(point_pair.validate_for_num_instances(14).is_ok()); - assert_eq!(point_pair.fixed_scalar_count(14), Ok(0)); + assert!(point_pair.validate_for_num_instances(12).is_ok()); + assert_eq!(point_pair.fixed_scalar_count(12), Ok(0)); + assert!(matches!( + point_pair.validate_for_num_instances(11), + Err(GeneratorError::UnsupportedAccumulatorEncoding { + reason: "accumulator public-input tail exceeds num_instances", + .. + }) + )); + // The flagged 10-word point pair would leave two trailing words. assert!(matches!( - point_pair.fixed_scalar_count(15), + point_pair.fixed_scalar_count(14), Err(GeneratorError::UnsupportedAccumulatorEncoding { reason: "point-pair accumulator encoding does not support a fixed-base scalar tail", .. diff --git a/proofs/solidity-verifier/src/test.rs b/proofs/solidity-verifier/src/test.rs index 0c153b03c..f6677939e 100644 --- a/proofs/solidity-verifier/src/test.rs +++ b/proofs/solidity-verifier/src/test.rs @@ -16,16 +16,21 @@ use std::{ use ff::Field; use group::{Curve as _, Group as _}; +#[cfg(feature = "truncated-challenges")] +use midnight_circuits::{ + ecc::curves::CircuitCurve, + types::{AssignedField, Instantiable}, + verifier::{Accumulator, AssignedAccumulator, BlstrsEmulation, Msm}, +}; use midnight_circuits::{ hash::poseidon::PoseidonChip, instructions::{hash::HashCPU, AssignmentInstructions, PublicInputInstructions}, }; #[cfg(feature = "truncated-challenges")] -use midnight_circuits::{ - types::Instantiable, - verifier::{Accumulator, AssignedAccumulator, BlstrsEmulation, Msm}, -}; +use midnight_curves::Fp; use midnight_curves::{Bls12, Fq, G1Projective, G2Projective}; +#[cfg(feature = "truncated-challenges")] +use midnight_proofs::poly::{commitment::PolynomialCommitmentScheme, EvaluationDomain}; use midnight_proofs::{ circuit::{Layouter, SimpleFloorPlanner, Value}, plonk::{ @@ -216,14 +221,16 @@ fn words_to_bytes(words: [U256; N]) -> Vec { words.into_iter().flat_map(|word| word.to_be_bytes::<32>()).collect() } +/// Binds every public instance word to an advice cell, so tests can prove +/// arbitrary accumulator public inputs and isolate the Solidity decoder. #[cfg(feature = "truncated-challenges")] #[derive(Clone, Debug)] -struct IdentityAccumulatorCircuit { +struct PublicWordsCircuit { instances: Vec, } #[cfg(feature = "truncated-challenges")] -impl Circuit for IdentityAccumulatorCircuit { +impl Circuit for PublicWordsCircuit { type Config = (Column, Selector); type FloorPlanner = SimpleFloorPlanner; type Params = (); @@ -259,7 +266,7 @@ impl Circuit for IdentityAccumulatorCircuit { mut layouter: impl Layouter, ) -> Result<(), Error> { layouter.assign_region( - || "identity accumulator words", + || "public words", |mut region| { for (row, value) in self.instances.iter().enumerate() { config.1.enable(&mut region, row)?; @@ -299,7 +306,7 @@ fn identity_accumulator_proof_verifies_on_evm() { AccumulatorEncoding::FULLY_COLLAPSED_PUBLIC_INPUT_WORDS ); assert_eq!((public[4], public[10]), (F::ONE, F::ONE)); - let circuit = IdentityAccumulatorCircuit { + let circuit = PublicWordsCircuit { instances: public.clone(), }; @@ -332,7 +339,7 @@ fn identity_accumulator_proof_verifies_on_evm() { ("canonical identity coordinates", public), ("unconstrained identity coordinates", unused_high_bits), ] { - let circuit = IdentityAccumulatorCircuit { + let circuit = PublicWordsCircuit { instances: instances.clone(), }; let committed = vec![F::ZERO; instances.len()]; @@ -374,6 +381,143 @@ fn identity_accumulator_proof_verifies_on_evm() { } } +/// Moonlight wrap encoding of an accumulator point: the x and y coordinates, +/// each through `AssignedField::as_public_input`, with no identity-flag word. +#[cfg(feature = "truncated-challenges")] +fn point_pair_coordinate_words(x: &Fp, y: &Fp) -> Vec { + [ + AssignedField::::as_public_input(x), + AssignedField::::as_public_input(y), + ] + .concat() +} + +/// A point-pair accumulator (`lhs.x, lhs.y, rhs.x, rhs.y`, no identity flags) +/// must verify on-chain exactly when it is a valid non-identity accumulator. +/// Every case is a valid PLONK proof of the instance words, so rejections come +/// from the accumulator handling. Identity points encoded as zero coordinates +/// satisfy the accumulator pairing trivially, so rejecting them shows the +/// decoder itself refuses the EIP-2537 infinity encoding. +#[cfg(feature = "truncated-challenges")] +#[test] +fn point_pair_accumulator_proof_verifies_on_evm() { + if !pinned_solc_available() { + return; + } + + let k = 6; + let mut rng = ChaCha8Rng::seed_from_u64(0x9a17); + let params = PoseidonParams::unsafe_setup(k, &mut rng); + // The accumulator invariant is e(lhs, [tau]_2) == e(rhs, [1]_2), so + // (G, [tau]G) is a valid non-identity pair and (tau G, G) is not. + let domain = EvaluationDomain::::new(1, k); + let commit_monomial = |degree: usize| { + let mut coeffs = vec![F::ZERO; 1 << k]; + coeffs[degree] = F::ONE; + KZGCommitmentScheme::::commit(¶ms, &domain.coeff_from_vec(coeffs)) + }; + let (lhs, rhs) = (commit_monomial(0), commit_monomial(1)); + let accumulator_holds = |lhs: G1Projective, rhs: G1Projective| { + let unit = |point| Msm::new(&[point], &[F::ONE], &Default::default()); + Accumulator::::new(unit(lhs), unit(rhs)) + .check(¶ms.verifier_params(), &Default::default()) + }; + let identity = G1Projective::identity(); + assert!(accumulator_holds(lhs, rhs)); + assert!(!accumulator_holds(rhs, lhs)); + assert!(accumulator_holds(identity, identity)); + + let encode = |points: [G1Projective; 2]| { + points + .iter() + .flat_map(|point| { + let (x, y) = point.coordinates().unwrap_or((Fp::ZERO, Fp::ZERO)); + point_pair_coordinate_words(&x, &y) + }) + .collect::>() + }; + let valid = encode([lhs, rhs]); + assert_eq!( + valid.len(), + AccumulatorEncoding::POINT_PAIR_PUBLIC_INPUT_WORDS + ); + + let circuit = PublicWordsCircuit { + instances: valid.clone(), + }; + let vk = keygen_vk_with_k::, _>(¶ms, &circuit, k) + .expect("point-pair accumulator VK"); + let pk = keygen_pk(vk, &circuit).expect("point-pair accumulator PK"); + let generator = SolidityGenerator::new( + ¶ms, + pk.get_vk(), + GeneratorConfig::new(valid.len(), 1) + .with_accumulator(AccumulatorEncoding::point_pair(0, 7, 56)), + ); + let artifacts = generator + .render(RenderOptions { + vk: RenderVk::Separate, + ..RenderOptions::default() + }) + .expect("render point-pair accumulator verifier"); + let mut deployed = deploy_separate_verifier_from_sources( + &artifacts.verifier, + &artifacts.verifying_key.expect("separate VK"), + ); + + for (name, instances, accepted) in [ + ("valid non-identity point pair", valid, true), + ("swapped point pair", encode([rhs, lhs]), false), + ( + "identity points as zero coordinates", + encode([identity, identity]), + false, + ), + ] { + let circuit = PublicWordsCircuit { + instances: instances.clone(), + }; + let committed = vec![F::ZERO; instances.len()]; + let all_instance_columns: [&[F]; 2] = [&committed, &instances]; + let mut transcript = CircuitTranscript::::init(); + create_proof::, _, _>( + ¶ms, + &pk, + std::slice::from_ref(&circuit), + 1, + &[&all_instance_columns], + &mut rng, + &mut transcript, + ) + .expect("point-pair accumulator proof"); + let compressed_proof = transcript.finalize(); + + let committed_pi = [identity]; + let public_columns: [&[F]; 1] = [&instances]; + let mut transcript = + CircuitTranscript::::init_from_bytes(&compressed_proof); + let guard = prepare::, CircuitTranscript>( + pk.get_vk(), + &[&committed_pi], + &[&public_columns], + &mut transcript, + ) + .expect("native point-pair accumulator prepare"); + transcript.assert_empty().expect("native proof consumed"); + guard + .verify(¶ms.verifier_params()) + .expect("native point-pair accumulator verify"); + + let proof = generator.repack_proof(&compressed_proof).expect("repack point-pair proof"); + let output = call_deployed_verifier(&mut deployed, &proof, &instances); + if accepted { + assert_solidity_accepts(output, name); + } else { + assert_solidity_rejects(output, name); + } + } +} + #[derive(Clone, Copy, Debug, Default)] struct ShapeFuzzSpec { next_rotation: bool, diff --git a/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul b/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul index a9048cb87..74380476f 100644 --- a/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul +++ b/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul @@ -1,18 +1,31 @@ // ---------- IVC accumulator public-input decoding ---------- // + {%- if self.expected_acc_non_identity_points %} + // The Moonlight wrap circuit constrains both accumulator points to + // be non-identity and exposes each one as its x and y coordinates, + // each through `AssignedField::as_public_input`: seven radix-2^56 + // limbs of (coord - 1) are packed four-at-a-time into native field + // elements. There is no identity-flag word. Rebuild EIP-2537 + // padded (x_hi, x_lo, y_hi, y_lo) words from that encoding. + {%- else %} // `AssignedForeignPoint` exposes each base-field coordinate // through `AssignedField::as_public_input`: seven radix-2^56 limbs of // (coord - 1) are packed four-at-a-time into native field elements. // The point's identity flag follows the y coordinate as its own // public-input word. Rebuild EIP-2537 padded // (x_hi, x_lo, y_hi, y_lo) words from that encoding. + {%- endif %} // // Public-input layout for one coordinate: // word 0: limb_0 | limb_1 << bits | ... up to limbs_per_word // word 1: next limbs, if any // // Public-input layout for one point: + {%- if self.expected_acc_non_identity_points %} + // x coordinate words, y coordinate words + {%- else %} // x coordinate words, y coordinate words, identity flag word + {%- endif %} // // The limbs are little-endian in the represented integer even // though calldata words are loaded as big 256-bit values. The loop @@ -113,6 +126,39 @@ ok := and(ok, lt(hi, shl(128, 1))) } + {% if self.expected_acc_non_identity_points -%} + // Decode a public accumulator point into an EIP-2537 4-word G1 + // slot. This layout has no identity flag: the circuit constrains + // the point to be non-identity, so both coordinates are always + // decoded and the EIP-2537 infinity encoding is rejected. Points + // are curve/subgroup checked later by routing them through G1MSM. + function load_acc_point(dst, src, bits, n, base) -> ok { + // x and y occupy coord_words packed public-input words each. + let limbs_per_word := {{ template_constants.accumulator.limbs_per_word }} + let coord_words := div(add(n, sub(limbs_per_word, 1)), limbs_per_word) + let x_ok, x_hi, x_lo := load_acc_coord(src, bits, n, base, limbs_per_word) + let y_ok, y_hi, y_lo := load_acc_coord( + add(src, mul(coord_words, 0x20)), + bits, + n, + base, + limbs_per_word + ) + ok := and(x_ok, y_ok) + // The coordinate codec maps encoded p-1 to decoded zero. + // EIP-2537 reserves affine (0,0) for the point at infinity, + // which the circuit never exposes here, so reject it rather + // than let G1MSM accept it as the identity. + let decoded_zero := iszero(or(or(x_hi, x_lo), or(y_hi, y_lo))) + ok := and(ok, iszero(decoded_zero)) + // Store the affine point in the exact precompile input + // layout: x_hi, x_lo, y_hi, y_lo. + mstore(dst, x_hi) + mstore(add(dst, 0x20), x_lo) + mstore(add(dst, 0x40), y_hi) + mstore(add(dst, 0x60), y_lo) + } + {%- else -%} // Decode a public accumulator point into an EIP-2537 4-word G1 // slot. Non-identity points are curve/subgroup checked later by // routing them through G1MSM. @@ -163,6 +209,7 @@ mstore(add(dst, 0x60), y_lo) } } + {%- endif %} {%- if self.expected_has_accumulator %} // Validate and prepare the public accumulator equation before the @@ -186,20 +233,34 @@ let limb_base := shl(bits, 1) let limbs_per_word := {{ template_constants.accumulator.limbs_per_word }} let coord_words := div(add(n, sub(limbs_per_word, 1)), limbs_per_word) + {%- if self.expected_acc_non_identity_points %} + // One public point is its x and y coordinate words. This + // layout carries no identity flag word. + let point_words := mul(2, coord_words) + {%- else %} // One public point is its x and y coordinate words followed // by the identity flag word. let point_words := add(mul(2, coord_words), {{ template_constants.accumulator.identity_flag_words }}) + {%- endif %} // acc_offset is generated from the VK/protocol shape and // points into the ABI `instances` array. let acc_instance_ptr := add(INSTANCE_CPTR, {{ (self.expected_acc_offset * 32)|hex() }}) - // LHS layout: point limbs (x,y), identity flag, then either an - // explicit scalar word or an implicit unit scalar for - // already-collapsed point-pair public inputs. + {% if self.expected_acc_non_identity_points -%} + // LHS layout: point limbs (x,y) only. Point pairs carry no + // scalar word; the LHS scalar is an implicit one. + {%- else -%} + // LHS layout: point limbs (x,y), identity flag, then the + // explicit LHS scalar word. + {%- endif %} // The scalar pointer is computed unconditionally; the rendered // branch below decides whether to read it or use scalar 1. let lhs_scalar_ptr := add(acc_instance_ptr, mul(point_words, 0x20)) + {%- if self.expected_acc_non_identity_points %} + let lhs_ok := load_acc_point(ACC_LHS_MPTR, acc_instance_ptr, bits, n, limb_base) + {%- else %} let lhs_ok, lhs_is_id := load_acc_point(ACC_LHS_MPTR, acc_instance_ptr, bits, n, limb_base) + {%- endif %} out := and(out, lhs_ok) // Shared scratch for one-pair LHS validation and the later // variable-length RHS MSM. @@ -214,6 +275,12 @@ // implicit one. let lhs_scalar := 1 {%- endif %} + {%- if self.expected_acc_non_identity_points %} + // Route the decoded LHS point through G1MSM with its + // implicit unit scalar; the precompile is the + // on-curve/subgroup validator for this public-input point. + // Identity points were already rejected by load_acc_point. + {%- else %} // Identity status is useful for decoding checks above, but // validation still goes through G1MSM for all points. pop(lhs_is_id) @@ -222,6 +289,7 @@ // precompile is the on-curve/subgroup validator for this // public-input point; skipping it would let a malformed // non-identity point hide behind scalar 0. + {%- endif %} mcopy(acc_scratch, ACC_LHS_MPTR, 0x80) mstore(add(acc_scratch, 0x80), lhs_scalar) if out { @@ -242,9 +310,9 @@ // accumulator construction. {%- else %} // RHS layout for this generated verifier is an already - // collapsed point pair: lhs point, rhs point. Both carried - // scalars are implicit one, and there is no fixed-base scalar - // tail. + // collapsed point pair: lhs point (x,y), rhs point (x,y). + // Neither point carries an identity flag, both carried scalars + // are implicit one, and there is no fixed-base scalar tail. {%- endif %} {%- else %} // RHS layout for this generated verifier is partially @@ -258,11 +326,17 @@ {%- else %} let rhs_instance_ptr := lhs_scalar_ptr {%- endif %} + {%- if self.expected_acc_has_carried_scalars %} // RHS scalar, when present, immediately follows the RHS point // limbs and identity flag. The fixed-base scalar tail starts // after it. let rhs_scalar_ptr := add(rhs_instance_ptr, mul(point_words, 0x20)) + {%- endif %} + {%- if self.expected_acc_non_identity_points %} + let rhs_ok := load_acc_point(ACC_RHS_MPTR, rhs_instance_ptr, bits, n, limb_base) + {%- else %} let rhs_ok, rhs_is_id := load_acc_point(ACC_RHS_MPTR, rhs_instance_ptr, bits, n, limb_base) + {%- endif %} out := and(out, rhs_ok) // acc_pair_ptr appends (G1, scalar) pairs into acc_scratch for // one final RHS MSM. @@ -275,11 +349,17 @@ // Implicit unit scalar for already-collapsed point pairs. let rhs_scalar := 1 {%- endif %} + {%- if self.expected_acc_non_identity_points %} + // Add the carried RHS point to the MSM input with its + // implicit unit scalar so EIP-2537 validates it before it + // reaches the pairing batch. + {%- else %} pop(rhs_is_id) // Keep the carried RHS point in the MSM input even when // it is encoded as identity or has scalar 0/1, so EIP-2537 // validates every decoded public accumulator point before // it can affect, or be erased from, the pairing batch. + {%- endif %} mcopy(acc_pair_ptr, ACC_RHS_MPTR, {{ template_constants.g1_bytes|hex() }}) mstore(add(acc_pair_ptr, {{ template_constants.g1_bytes|hex() }}), rhs_scalar) // Move to the next (G1, scalar) pair slot.