From b12c6a883210709ea775ecb68f81089be79e1eff Mon Sep 17 00:00:00 2001 From: Michael Livesey Date: Wed, 28 Oct 2026 11:47:26 +0000 Subject: [PATCH 01/12] fix(solidity-verifier): decode point-pair accumulators without identity flags Moonlight's WrapCircuit now constrains both points of its fully collapsed (lhs, rhs) accumulator to be non-identity and exposes only their x and y coordinates, dropping the trailing is_id word from each point. AccumulatorEncoding::point_pair now matches that layout: each point is 2 * coord_words public-input words, POINT_PAIR_PUBLIC_INPUT_WORDS is 8, and the generated verifier always decodes both coordinates and rejects a point that decodes to the EIP-2537 identity encoding (0, 0). The point-and-scalar (IVC) layout keeps its identity-flag decoding, and its rendered Solidity, like that of verifiers without an accumulator, is byte-identical to before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- proofs/CHANGELOG.md | 3 +- .../LOWERING_ARCHITECTURE_SPEC.md | 3 +- .../reference/HALO2_MIDNIGHT_VERIFIER_SPEC.md | 35 +++-- .../docs/reference/TEAM_DEMO_SETUP.md | 5 +- proofs/solidity-verifier/src/api.rs | 43 +++++-- .../src/lowering/artifacts.rs | 5 +- .../src/lowering/render/models.rs | 6 + .../solidity-verifier/src/lowering/tests.rs | 120 +++++++++++++++++- .../partials/verifier/AccumulatorHelpers.yul | 82 +++++++++++- 9 files changed, 270 insertions(+), 32 deletions(-) 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/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..cf9369865 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: @@ -1419,10 +1434,10 @@ 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 +1453,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..bd9d8cc09 100644 --- a/proofs/solidity-verifier/docs/reference/TEAM_DEMO_SETUP.md +++ b/proofs/solidity-verifier/docs/reference/TEAM_DEMO_SETUP.md @@ -254,8 +254,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/src/api.rs b/proofs/solidity-verifier/src/api.rs index c0a29bb5c..284dd8a7d 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() 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..8d1615ed6 100644 --- a/proofs/solidity-verifier/src/lowering/render/models.rs +++ b/proofs/solidity-verifier/src/lowering/render/models.rs @@ -526,6 +526,11 @@ pub(crate) struct Halo2Verifier { /// words. Moonlight wrap proofs expose an already collapsed point pair and /// use implicit unit scalars instead. 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. + 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 +1151,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..25998594a 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,110 @@ 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()) + ); +} + +/// 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 +1659,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/templates/partials/verifier/AccumulatorHelpers.yul b/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul index a9048cb87..fca6c61ca 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,36 @@ 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() }}) + {% if self.expected_acc_non_identity_points -%} + // LHS layout: point limbs (x,y), then either an explicit + // scalar word or an implicit unit scalar for already-collapsed + // point-pair public inputs. + {%- else -%} // 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. + {%- 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,9 +277,11 @@ // implicit one. let lhs_scalar := 1 {%- endif %} + {%- if !self.expected_acc_non_identity_points %} // Identity status is useful for decoding checks above, but // validation still goes through G1MSM for all points. pop(lhs_is_id) + {%- endif %} // Always route the decoded carried point through G1MSM, // even for identity points and zero/one scalars. The // precompile is the on-curve/subgroup validator for this @@ -242,9 +307,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 +323,20 @@ {%- else %} let rhs_instance_ptr := lhs_scalar_ptr {%- endif %} + {%- if self.expected_acc_non_identity_points %} + // RHS scalar, when present, immediately follows the RHS point + // coordinate words. The fixed-base scalar tail starts after it. + {%- else %} // RHS scalar, when present, immediately follows the RHS point // limbs and identity flag. The fixed-base scalar tail starts // after it. + {%- endif %} let rhs_scalar_ptr := add(rhs_instance_ptr, mul(point_words, 0x20)) + {%- 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,7 +349,9 @@ // Implicit unit scalar for already-collapsed point pairs. let rhs_scalar := 1 {%- endif %} + {%- if !self.expected_acc_non_identity_points %} pop(rhs_is_id) + {%- endif %} // 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 From 32e74c83a7d8d88c12cdc01bf8fc6f79e9a5d57d Mon Sep 17 00:00:00 2001 From: Michael Livesey Date: Wed, 28 Oct 2026 11:47:26 +0000 Subject: [PATCH 02/12] test(solidity-verifier): verify point-pair accumulators on EVM Prove Moonlight-style point-pair public inputs with a circuit that binds arbitrary instance words, and check that the rendered verifier accepts a valid non-identity accumulator, rejects a swapped pair, and rejects identity points encoded as zero coordinates. The identity pair satisfies the accumulator pairing, so its rejection comes from the decoder itself. Rename IdentityAccumulatorCircuit to PublicWordsCircuit now that it is used for non-identity accumulators as well. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- proofs/solidity-verifier/src/test.rs | 158 +++++++++++++++++++++++++-- 1 file changed, 150 insertions(+), 8 deletions(-) diff --git a/proofs/solidity-verifier/src/test.rs b/proofs/solidity-verifier/src/test.rs index 0c153b03c..04e1a0f76 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 = (); @@ -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,141 @@ 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); + prepare::, CircuitTranscript>( + pk.get_vk(), + &[&committed_pi], + &[&public_columns], + &mut transcript, + ) + .expect("native point-pair accumulator prepare") + .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, From 1f55dad7a5ca005ae15aecdf15f11c56838db1b8 Mon Sep 17 00:00:00 2001 From: Michael Livesey Date: Wed, 28 Oct 2026 13:58:48 +0000 Subject: [PATCH 03/12] docs(solidity-verifier): mark Moonlight Sepolia deployment as historical The moonlight-wrap contracts were deployed once, on 2026-05-19, from a synthetic wrap benchmark with a seeded test SRS, and have not been redeployed since. Note that they no longer match current generator output or Moonlight wrap proofs, and that the files are an exact record of what is on-chain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../deployments/sepolia/moonlight-wrap/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 | From 8afa26b7fd8181e7aeee87eebc3cab111a875559 Mon Sep 17 00:00:00 2001 From: Michael Livesey Date: Wed, 28 Oct 2026 14:53:34 +0000 Subject: [PATCH 04/12] fix(solidity-verifier): reject point-pair accumulator tails in try_new SolidityGenerator::try_new only checked that the accumulator payload fit in num_instances. A point-pair encoding with trailing words, such as the old flagged 10-word Moonlight layout, was accepted and then panicked during rendering where lowering expects the tail check to have passed. Run fixed_scalar_count in try_new so those layouts return a typed UnsupportedAccumulatorEncoding error up front, and test the 18/19/21-word boundary at Moonlight's offset of 11. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- proofs/solidity-verifier/src/builder/api.rs | 4 ++- .../solidity-verifier/src/lowering/tests.rs | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/proofs/solidity-verifier/src/builder/api.rs b/proofs/solidity-verifier/src/builder/api.rs index 81adcb909..2306d588d 100644 --- a/proofs/solidity-verifier/src/builder/api.rs +++ b/proofs/solidity-verifier/src/builder/api.rs @@ -47,7 +47,9 @@ impl<'a> SolidityGenerator<'a> { config.num_committed_instances, )?; if let Some(acc_encoding) = config.accumulator { - acc_encoding.validate_for_num_instances(config.num_instances)?; + // Also rejects trailing words for layouts without a fixed-base + // scalar tail, which later lowering assumes were validated here. + acc_encoding.fixed_scalar_count(config.num_instances)?; } if let Some((column, rotation)) = vk .cs() diff --git a/proofs/solidity-verifier/src/lowering/tests.rs b/proofs/solidity-verifier/src/lowering/tests.rs index 25998594a..03250d79b 100644 --- a/proofs/solidity-verifier/src/lowering/tests.rs +++ b/proofs/solidity-verifier/src/lowering/tests.rs @@ -1583,6 +1583,39 @@ fn point_pair_word_counts_match_coordinate_encoding() { ); } +/// `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", + .. + }) + )); +} + /// 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. From b53575e794f8547e9afc49c8ff13bad0e3366682 Mon Sep 17 00:00:00 2001 From: Michael Livesey Date: Wed, 28 Oct 2026 18:10:22 +0000 Subject: [PATCH 05/12] docs(solidity-verifier): flag stale Moonlight demo branch The team demo's default Moonlight branch, codex/wrap-bench-cherry-picks, predates EYBlockchain/midfall#15 (dedicated foreign-point identity-flag words) and EYBlockchain/Moonlight#91 (flagless, non-identity wrap accumulator), so its wrap bench no longer runs against current Midfall. Note this in TEAM_DEMO_SETUP.md and in run_team_demo.sh, and point users at --skip-moonlight until the branch is rebased onto Moonlight master. Updating the pin is deferred to a follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/reference/TEAM_DEMO_SETUP.md | 12 ++++++++++++ proofs/solidity-verifier/scripts/run_team_demo.sh | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/proofs/solidity-verifier/docs/reference/TEAM_DEMO_SETUP.md b/proofs/solidity-verifier/docs/reference/TEAM_DEMO_SETUP.md index bd9d8cc09..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 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. From 5d986d17636ad9620f6bb00a3d8b6473045805e3 Mon Sep 17 00:00:00 2001 From: Michael Livesey Date: Wed, 28 Oct 2026 19:19:08 +0000 Subject: [PATCH 06/12] fixup! fix(solidity-verifier): decode point-pair accumulators without identity flags Make the accumulator layout comments match the branch they render in: point pairs have no LHS/RHS scalar words or fixed-base tail, and the point-and-scalar layout always has an explicit LHS scalar. Comment-only; the compiled IVC and point-pair verifier bytecode is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../partials/verifier/AccumulatorHelpers.yul | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul b/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul index fca6c61ca..37b1bf729 100644 --- a/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul +++ b/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul @@ -247,13 +247,11 @@ let acc_instance_ptr := add(INSTANCE_CPTR, {{ (self.expected_acc_offset * 32)|hex() }}) {% if self.expected_acc_non_identity_points -%} - // LHS layout: point limbs (x,y), then either an explicit - // scalar word or an implicit unit scalar for already-collapsed - // point-pair public inputs. + // 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 either an - // explicit scalar word or an implicit unit scalar for - // already-collapsed point-pair public inputs. + // 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. @@ -324,8 +322,8 @@ let rhs_instance_ptr := lhs_scalar_ptr {%- endif %} {%- if self.expected_acc_non_identity_points %} - // RHS scalar, when present, immediately follows the RHS point - // coordinate words. The fixed-base scalar tail starts after it. + // Point pairs carry no RHS scalar or fixed-base tail; + // `rhs_scalar_ptr` only marks the end of the payload. {%- else %} // RHS scalar, when present, immediately follows the RHS point // limbs and identity flag. The fixed-base scalar tail starts From 085e7ecbcd359e2984fb984d1eaa0db36d60afee Mon Sep 17 00:00:00 2001 From: Michael Livesey Date: Wed, 28 Oct 2026 19:39:31 +0000 Subject: [PATCH 07/12] fixup! fix(solidity-verifier): decode point-pair accumulators without identity flags Gate the G1MSM validation rationale on expected_acc_non_identity_points. For point pairs an identity point already fails load_acc_point and the scalar is always one, so the identity and zero-scalar wording only applies to the flagged layout. Comment-only: IVC renders are unchanged and the point-pair verifier bytecode is identical. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../partials/verifier/AccumulatorHelpers.yul | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul b/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul index 37b1bf729..2ed406853 100644 --- a/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul +++ b/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul @@ -275,16 +275,21 @@ // implicit one. let lhs_scalar := 1 {%- endif %} - {%- if !self.expected_acc_non_identity_points %} + {%- 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) - {%- endif %} // Always route the decoded carried point through G1MSM, // even for identity points and zero/one scalars. The // 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 { @@ -347,13 +352,17 @@ // Implicit unit scalar for already-collapsed point pairs. let rhs_scalar := 1 {%- endif %} - {%- if !self.expected_acc_non_identity_points %} + {%- 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) - {%- endif %} // 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. From 87312af4dd83b2610c98917144f2dfe9985cdd11 Mon Sep 17 00:00:00 2001 From: Michael Livesey Date: Wed, 28 Oct 2026 19:45:37 +0000 Subject: [PATCH 08/12] fixup! fix(solidity-verifier): decode point-pair accumulators without identity flags Render rhs_scalar_ptr and its comment only for the flagged layout. Point pairs have no RHS scalar or fixed-base tail, so the pointer was never read in their renders. IVC renders are unchanged and the point-pair verifier bytecode is identical. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../templates/partials/verifier/AccumulatorHelpers.yul | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul b/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul index 2ed406853..4e4ad11bf 100644 --- a/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul +++ b/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul @@ -326,15 +326,12 @@ {%- else %} let rhs_instance_ptr := lhs_scalar_ptr {%- endif %} - {%- if self.expected_acc_non_identity_points %} - // Point pairs carry no RHS scalar or fixed-base tail; - // `rhs_scalar_ptr` only marks the end of the payload. - {%- else %} + {%- if !self.expected_acc_non_identity_points %} // RHS scalar, when present, immediately follows the RHS point // limbs and identity flag. The fixed-base scalar tail starts // after it. - {%- endif %} 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 %} From 75fd4e6fdea809bce624bffc406d62d4b6c0e038 Mon Sep 17 00:00:00 2001 From: Michael Livesey Date: Wed, 28 Oct 2026 19:55:10 +0000 Subject: [PATCH 09/12] fixup! fix(solidity-verifier): decode point-pair accumulators without identity flags Gate rhs_scalar_ptr on expected_acc_has_carried_scalars, which matches where it is read and removes the last negated expected_acc_non_identity_points gate. Document that both template flags are derived from AccumulatorEncodingKind, so only two combinations are reachable, and that the template relies on this. All renders are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- proofs/solidity-verifier/src/lowering/render/models.rs | 10 ++++++++++ .../templates/partials/verifier/AccumulatorHelpers.yul | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/proofs/solidity-verifier/src/lowering/render/models.rs b/proofs/solidity-verifier/src/lowering/render/models.rs index 8d1615ed6..7d6e8661b 100644 --- a/proofs/solidity-verifier/src/lowering/render/models.rs +++ b/proofs/solidity-verifier/src/lowering/render/models.rs @@ -525,11 +525,21 @@ 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 diff --git a/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul b/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul index 4e4ad11bf..74380476f 100644 --- a/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul +++ b/proofs/solidity-verifier/templates/partials/verifier/AccumulatorHelpers.yul @@ -326,7 +326,7 @@ {%- else %} let rhs_instance_ptr := lhs_scalar_ptr {%- endif %} - {%- if !self.expected_acc_non_identity_points %} + {%- 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. From bb35b3c9fd8e952bae6b87c7086c5fb10932423a Mon Sep 17 00:00:00 2001 From: Michael Livesey Date: Thu, 29 Oct 2026 09:39:18 +0000 Subject: [PATCH 10/12] fixup! test(solidity-verifier): verify point-pair accumulators on EVM Rename the PublicWordsCircuit region from "identity accumulator words" to "public words", which the circuit rename missed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- proofs/solidity-verifier/src/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proofs/solidity-verifier/src/test.rs b/proofs/solidity-verifier/src/test.rs index 04e1a0f76..1e9ca32e3 100644 --- a/proofs/solidity-verifier/src/test.rs +++ b/proofs/solidity-verifier/src/test.rs @@ -266,7 +266,7 @@ impl Circuit for PublicWordsCircuit { 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)?; From 130fab39335ef0e84711ed6fb9d4cef064f4145e Mon Sep 17 00:00:00 2001 From: Michael Livesey Date: Thu, 29 Oct 2026 09:47:38 +0000 Subject: [PATCH 11/12] fixup! test(solidity-verifier): verify point-pair accumulators on EVM Check that the native verifier consumes the whole proof between prepare and verify, matching identity_accumulator_proof_verifies_on_evm. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- proofs/solidity-verifier/src/test.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/proofs/solidity-verifier/src/test.rs b/proofs/solidity-verifier/src/test.rs index 1e9ca32e3..f6677939e 100644 --- a/proofs/solidity-verifier/src/test.rs +++ b/proofs/solidity-verifier/src/test.rs @@ -496,15 +496,17 @@ fn point_pair_accumulator_proof_verifies_on_evm() { let public_columns: [&[F]; 1] = [&instances]; let mut transcript = CircuitTranscript::::init_from_bytes(&compressed_proof); - prepare::, CircuitTranscript>( + let guard = prepare::, CircuitTranscript>( pk.get_vk(), &[&committed_pi], &[&public_columns], &mut transcript, ) - .expect("native point-pair accumulator prepare") - .verify(¶ms.verifier_params()) - .expect("native point-pair accumulator verify"); + .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); From 3ff53a4d17b1946c38e09a0ba53ce08ec3a8a92a Mon Sep 17 00:00:00 2001 From: Michael Livesey Date: Thu, 29 Oct 2026 11:22:48 +0000 Subject: [PATCH 12/12] fix(solidity-verifier): check fixed-base scalar tail against the VK For point-and-scalar accumulators, try_new accepted any fixed-base scalar tail length, but lowering maps a non-empty tail onto -G and every fixed and permutation commitment of the verified VK. A tail no longer than the permutation count panicked during rendering, other short tails mapped scalars onto a prefix of the fixed commitments, and long tails read base pointers past the fixed-commitment region, rendering a verifier that checked the accumulator against the wrong bases. fixed_bases(vk) always holds exactly 1 + #fixed + #permutation entries, so require a non-empty tail to have that length and return UnsupportedAccumulatorEncoding otherwise. Valid configurations, including the fully collapsed IVC accumulator, are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../reference/HALO2_MIDNIGHT_VERIFIER_SPEC.md | 4 + proofs/solidity-verifier/src/api.rs | 26 ++++++ proofs/solidity-verifier/src/builder/api.rs | 9 +- .../solidity-verifier/src/lowering/tests.rs | 86 +++++++++++++++++++ 4 files changed, 122 insertions(+), 3 deletions(-) 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 cf9369865..fe4e1610b 100644 --- a/proofs/solidity-verifier/docs/reference/HALO2_MIDNIGHT_VERIFIER_SPEC.md +++ b/proofs/solidity-verifier/docs/reference/HALO2_MIDNIGHT_VERIFIER_SPEC.md @@ -1428,6 +1428,10 @@ 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. diff --git a/proofs/solidity-verifier/src/api.rs b/proofs/solidity-verifier/src/api.rs index 284dd8a7d..36f226bd2 100644 --- a/proofs/solidity-verifier/src/api.rs +++ b/proofs/solidity-verifier/src/api.rs @@ -209,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 2306d588d..bdc689af1 100644 --- a/proofs/solidity-verifier/src/builder/api.rs +++ b/proofs/solidity-verifier/src/builder/api.rs @@ -47,9 +47,12 @@ impl<'a> SolidityGenerator<'a> { config.num_committed_instances, )?; if let Some(acc_encoding) = config.accumulator { - // Also rejects trailing words for layouts without a fixed-base - // scalar tail, which later lowering assumes were validated here. - acc_encoding.fixed_scalar_count(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/tests.rs b/proofs/solidity-verifier/src/lowering/tests.rs index 03250d79b..d12e10bd5 100644 --- a/proofs/solidity-verifier/src/lowering/tests.rs +++ b/proofs/solidity-verifier/src/lowering/tests.rs @@ -1616,6 +1616,92 @@ fn try_new_rejects_flagged_point_pair_layout() { )); } +/// 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.