diff --git a/CHANGELOG.md b/CHANGELOG.md index 478b1ab3f7..4b9f79b06f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ - Bound deferred precompile STARK proofs to the generated precompile ACE relation digest ([#3344](https://github.com/0xMiden/miden-vm/pull/3344)). - [BREAKING] Split Poseidon2 permutation rows out of `ChipletsAir` into `Poseidon2PermutationAir`, and updated the recursive verifier ACE registry for three AIRs ([#3345](https://github.com/0xMiden/miden-vm/pull/3345)). - [BREAKING] Optimize periodic columns evaluation for fewer ACE gates ([#3347](https://github.com/0xMiden/miden-vm/pull/3347)). +- [BREAKING] Optimize periodic columns evaluation for fewer ACE gates ([#3347](https://github.com/0xMiden/miden-vm/pull/3347)). +- [BREAKING] Moved the secp256k1 GLV endomorphism scalar decomposition from the ECDSA verifier's MASM/advice ABI into the precompiles prover's addition-chain strategy: `ecdsa_k256_keccak::verify` logs a plain `u1*G + u2*Q` claim, and the deferred prover satisfies it with a GLV-decomposed chain, certified in-circuit ([#3426](https://github.com/0xMiden/miden-vm/pull/3426)). - Split dense `MastForest` order helpers and package serialization tests into smaller modules, and routed dense forest finalization and static library setup through dedicated builder and library methods ([#3346](https://github.com/0xMiden/miden-vm/pull/3346)). - [BREAKING] Renamed module and kernel metadata APIs from `ModuleInfo`/`Kernel` to `ModuleDescriptor`/`KernelDescriptor`, including matching module descriptor method names ([#3356](https://github.com/0xMiden/miden-vm/pull/3356)). - Replaced panics in `OverflowTable::restore_context()`, `get_current_overflow_stack()`, and `get_current_overflow_stack_mut()` with proper `OperationError` returns ([#3370](https://github.com/0xMiden/miden-vm/pull/3370)). diff --git a/Cargo.lock b/Cargo.lock index 4d449c82c3..a038454da5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2213,6 +2213,7 @@ dependencies = [ "miden-mast-package", "miden-package-registry", "miden-precompiles", + "miden-precompiles-prover", "miden-processor", "miden-test-utils", "miden-utils-sync", @@ -2515,6 +2516,7 @@ version = "0.29.0" dependencies = [ "miden-core", "miden-crypto", + "ruint", ] [[package]] diff --git a/crates/lib/core/Cargo.toml b/crates/lib/core/Cargo.toml index 340e39151b..2b9bf2a38d 100644 --- a/crates/lib/core/Cargo.toml +++ b/crates/lib/core/Cargo.toml @@ -59,6 +59,7 @@ miden-air.workspace = true miden-assembly = { workspace = true, features = ["testing"] } miden-core-lib-codegen = { workspace = true, features = ["std"] } miden-package-registry = { workspace = true, features = ["resolver"] } +miden-precompiles-prover = { workspace = true, features = ["std"] } miden-processor = { workspace = true, features = ["testing"] } miden-utils-testing.workspace = true num = { workspace = true } diff --git a/crates/lib/core/asm/crypto/dsa/ecdsa_k256_keccak.masm b/crates/lib/core/asm/crypto/dsa/ecdsa_k256_keccak.masm index 4c3d75387f..cdaf637542 100644 --- a/crates/lib/core/asm/crypto/dsa/ecdsa_k256_keccak.masm +++ b/crates/lib/core/asm/crypto/dsa/ecdsa_k256_keccak.masm @@ -98,40 +98,67 @@ pub proc verify # => [PREHASH_U32[8], pubkey_ptr, ...] where PREHASH[0] = byte_reverse(KeccakDigest[7]) exec.prepare_verify_inputs - # => [U1, Q, U2, SIG_R, ...] + # => [U1, U2, SIG_R, pubkey_ptr, ...] - dupw.1 - # => [Q, U1, Q, U2, SIG_R, ...] + movup.12 + # => [pubkey_ptr, U1, U2, SIG_R, ...] + dup add.8 + # => [pubkey_ptr+8, pubkey_ptr, U1, U2, SIG_R, ...] + exec.k1_base::load_mem_stream + # => [BY, pubkey_ptr+16, pubkey_ptr, U1, U2, SIG_R, ...] + movup.5 + # => [pubkey_ptr, BY, pubkey_ptr+16, U1, U2, SIG_R, ...] + exec.k1_base::load_mem_stream + # => [BX, pubkey_ptr+8, BY, pubkey_ptr+16, U1, U2, SIG_R, ...] + movup.4 drop + # => [BX, BY, pubkey_ptr+16, U1, U2, SIG_R, ...] + movup.8 drop + # => [BX, BY, U1, U2, SIG_R, ...] + exec.secp256k1::load_digest_pair + # => [Q, U1, U2, SIG_R, ...] + movdnw.2 + # => [U1, U2, Q, SIG_R, ...] + exec.verify_point_2base + # => [VERIFY_POINT, SIG_R, ...] + exec.assert_x_eq_scalar_k1 + # => [...] +end + +# INTERNAL PROCEDURES — ECDSA +# ================================================================================================ + +#! Computes `R = u1*G + u2*Q` as a plain 2-base joint wNAF MSM, folding the degenerate `Q == G` +#! case into a single `(u1+u2)*G` multiply, the one collision a 2-base MSM has. +#! +#! Input: [U1, U2, Q, ...] +#! Output: [VERIFY_POINT, ...] +proc verify_point_2base + dupw.2 + # => [Q, U1, U2, Q, ...] exec.secp256k1::push_generator - # => [G, Q, U1, Q, U2, SIG_R, ...] + # => [G, Q, U1, U2, Q, ...] exec.word::eq - # => [is_q_generator, U1, Q, U2, SIG_R, ...] + # => [is_q_generator, U1, U2, Q, ...] if.true - movupw.2 swapw - # => [U1, U2, Q, SIG_R, ...] exec.k1_scalar::add - # => [U1_PLUS_U2, Q, SIG_R, ...] + # => [U1_PLUS_U2, Q, ...] swapw dropw - # => [U1_PLUS_U2, SIG_R, ...] + # => [U1_PLUS_U2, ...] exec.secp256k1::mul_scalar_generator else - exec.secp256k1::push_generator - # => [G, U1, Q, U2, SIG_R, ...] - exec.secp256k1::msm2 + exec.secp256k1::msm2_generator end - # => [VERIFY_POINT, SIG_R, ...] - exec.assert_x_eq_scalar_k1 - # => [...] + # => [VERIFY_POINT, ...] end -# INTERNAL PROCEDURES — ECDSA -# ================================================================================================ - #! Computes ECDSA scalars U1 = z/s and U2 = r/s, keeping SIG_R for the final check. #! #! Input: [Z_U32[8], pubkey_ptr, ...] #! Advice: [SIG_R[8] | SIG_S[8] | ...] -#! Output: [U1_DIGEST, Q_DIGEST, U2_DIGEST, SIG_R_DIGEST, ...] +#! Output: [U1_DIGEST, U2_DIGEST, SIG_R_DIGEST, pubkey_ptr, ...] +#! +#! `pubkey_ptr` rides through unread: the public key is loaded once, by the verification-point +#! procedure that also needs `Q`'s raw coordinate digests. #! #! Preconditions/provenance: #! - Z_U32[8] is the Keccak256 message digest converted to little-endian scalar limbs; @@ -141,36 +168,26 @@ proc prepare_verify_inputs exec.k1_scalar::load_reduced_256 # => [Z, pubkey_ptr, ...] - movup.4 - # => [pubkey_ptr, Z, ...] - exec.secp256k1::load_mem - # => [Q, Z, ...] - swapw - # => [Z, Q, ...] - exec.load_signature_k1 - # => [SIG_S, SIG_R, Z, Q, ...] + # => [SIG_S, SIG_R, Z, pubkey_ptr, ...] exec.k1_scalar::inv - # => [S_INV, SIG_R, Z, Q, ...] + # => [S_INV, SIG_R, Z, pubkey_ptr, ...] dupw movupw.3 exec.k1_scalar::mul - # => [U1 = Z*S_INV, S_INV, SIG_R, Q, ...] + # => [U1 = Z*S_INV, S_INV, SIG_R, pubkey_ptr, ...] dupw.1 dupw.3 exec.k1_scalar::mul - # => [U2 = SIG_R*S_INV, U1, S_INV, SIG_R, Q, ...] + # => [U2 = SIG_R*S_INV, U1, S_INV, SIG_R, pubkey_ptr, ...] swapw.2 - # => [S_INV, U1, U2, SIG_R, Q, ...] + # => [S_INV, U1, U2, SIG_R, pubkey_ptr, ...] dropw - # => [U1, U2, SIG_R, Q, ...] - - movupw.3 swapw - # => [U1, Q, U2, SIG_R, ...] + # => [U1, U2, SIG_R, pubkey_ptr, ...] end #! Loads SIG_R and SIG_S from advice as k1 scalar VALUE digests, checks SIG_R != 0, diff --git a/crates/lib/core/codegen/src/masm.rs b/crates/lib/core/codegen/src/masm.rs index d2b18e14b8..ee516b3198 100644 --- a/crates/lib/core/codegen/src/masm.rs +++ b/crates/lib/core/codegen/src/masm.rs @@ -147,6 +147,40 @@ fn constant(value: Limbs, domain: UintDomain) -> ConstantMasm { } } +/// Renders `curve.extra_points()` (identity/generator's siblings, e.g. a GLV endomorphism image) +/// as `NAME_DIGEST` constant declarations. [`CurveId::extra_points`] is the single source of +/// truth: [`CurvePrecompile::init`] seeds the same points into the deferred-DAG init node set, so +/// the MASM constant and the runtime registration cannot drift apart. +fn render_curve_extra_constants(curve: CurveId) -> String { + let points = curve.extra_points().into_iter().map(|(name, point)| { + let digest = CurvePrecompile::value_node(curve, point).digest(); + format!("const {name}_DIGEST = {}\n", word_literal(digest_word(digest))) + }); + let base_constants = curve.extra_base_constants().into_iter().map(|(name, limbs)| { + let digest = UintPrecompile::value_node(curve.base_domain(), limbs).digest(); + format!("const {name}_DIGEST = {}\n", word_literal(digest_word(digest))) + }); + points.chain(base_constants).collect() +} + +/// Renders `curve.extra_points()` and [`CurveId::extra_base_constants`] as `push_name` wrapper +/// procs (name lowercased), mirroring `GENERATOR_DIGEST`/`push_generator`. +fn render_curve_extra_procs(curve: CurveId) -> String { + let points = curve.extra_points().into_iter().map(|(name, _)| { + format!( + "\n#! Pushes the registered digest of the precomputed `{name}` point constant.\npub proc push_{proc_name}\n push.{name}_DIGEST\nend\n", + proc_name = name.to_lowercase(), + ) + }); + let base_constants = curve.extra_base_constants().into_iter().map(|(name, _)| { + format!( + "\n#! Pushes the canonical VALUE digest of the base-field constant `{name}`.\npub proc push_{proc_name}\n push.{name}_DIGEST\nend\n", + proc_name = name.to_lowercase(), + ) + }); + points.chain(base_constants).collect() +} + fn render_curve(config: &CurveMasmConfig) -> Result { let curve = config.curve; let op_tag = |op_id| word_literal(tag_word(CurvePrecompile::op_tag(op_id))); @@ -176,6 +210,8 @@ fn render_curve(config: &CurveMasmConfig) -> Result { "GENERATOR_DIGEST", word_literal(digest_word(CurvePrecompile::generator_node(curve).digest())), ), + ("EXTRA_CONSTANTS", render_curve_extra_constants(curve)), + ("EXTRA_PROCS", render_curve_extra_procs(curve)), ]; render_template(CURVE_TEMPLATE, &replacements) diff --git a/crates/lib/core/codegen/src/templates/curve.masm.tpl b/crates/lib/core/codegen/src/templates/curve.masm.tpl index a72a81690a..6ba462bf20 100644 --- a/crates/lib/core/codegen/src/templates/curve.masm.tpl +++ b/crates/lib/core/codegen/src/templates/curve.masm.tpl @@ -47,6 +47,7 @@ const MSM_TAG = {{MSM_TAG}} # Registered digests for CurvePrecompile init constants. const IDENTITY_DIGEST = {{IDENTITY_DIGEST}} const GENERATOR_DIGEST = {{GENERATOR_DIGEST}} +{{EXTRA_CONSTANTS}} #! Constructs an affine curve VALUE node from two coordinate digests. #! Input: [X_DIGEST, Y_DIGEST, ...] @@ -131,6 +132,7 @@ end pub proc push_generator push.GENERATOR_DIGEST end +{{EXTRA_PROCS}} #! Registers `lhs + rhs` and returns the result expression digest. diff --git a/crates/lib/core/src/dsa.rs b/crates/lib/core/src/dsa.rs index 2a78aa6466..1a8fcea3c2 100644 --- a/crates/lib/core/src/dsa.rs +++ b/crates/lib/core/src/dsa.rs @@ -47,10 +47,10 @@ pub mod ecdsa_k256_keccak { /// by `ecdsa_k256_keccak::verify`. /// /// The encoding is the structural order consumed from the advice stack: - /// `[QX[8] || QY[8] || SIG_R[8] || SIG_S[8]]`, where each value is a little-endian `u32` limb - /// represented as a field element. This preserves `r` and `s` exactly, omits the recovery ID, - /// and does not normalize or enforce low-s. The result is advice witness data, not a commitment - /// to the supplied signature encoding. + /// `[QX[8] || QY[8] || SIG_R[8] || SIG_S[8]]`, where each scalar value is a little-endian + /// `u32` limb represented as a field element. The signature portion preserves `r` and `s` + /// exactly, omits the recovery ID, and does not normalize or enforce low-s. The result is + /// advice witness data, not a commitment to the supplied signature encoding. /// /// The public-key elements come from [`SequentialCommit::to_elements()`], matching the /// commitment returned by [`public_key_commitment()`]. @@ -62,7 +62,7 @@ pub mod ecdsa_k256_keccak { "ECDSA public key elements must be QX[8] || QY[8] native limbs", ); - let mut out = Vec::with_capacity(32); + let mut out = Vec::with_capacity(16 + 16); out.extend(pk_elements); out.extend_from_slice(&signature_felts(sig)); out diff --git a/crates/lib/core/tests/crypto/dsa.rs b/crates/lib/core/tests/crypto/dsa.rs index 3d225660f8..5e1d25f4c5 100644 --- a/crates/lib/core/tests/crypto/dsa.rs +++ b/crates/lib/core/tests/crypto/dsa.rs @@ -11,7 +11,8 @@ use miden_crypto::{ SequentialCommit, dsa::ecdsa_k256_keccak::{PublicKey, Signature, SigningKey}, }; -use miden_precompiles::K1Scalar; +use miden_precompiles::{K1Scalar, SECP256K1_LAMBDA, scalar_mul_mod_n}; +use miden_precompiles_prover::{HashFunction, prove_deferred_state, verify_deferred}; use miden_processor::{ DefaultHost, ExecutionError, ExecutionOptions, ExecutionOutput, FastProcessor, StackInputs, advice::{AdviceInputs, AdviceStack}, @@ -19,7 +20,7 @@ use miden_processor::{ use miden_utils_testing::crypto::Poseidon2; use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; -const VERIFY_EXPECTED_CYCLES: u64 = 1_453; +const VERIFY_EXPECTED_CYCLES: u64 = 1_465; const VERIFY_EXPECTED_WIRE_ENTRIES: usize = 36; const VERIFY_EXPECTED_WIRE_BYTES: usize = 2_455; @@ -35,12 +36,61 @@ fn core_ecdsa_k256_keccak_verify_accepts_valid_signature() { assert_eq!(wire.to_bytes().len(), VERIFY_EXPECTED_WIRE_BYTES); } +/// Full round trip through the real precompile side prover: `verify` logs a plain 2-base MSM +/// claim (`R = u1*G + u2*Q`), and the side prover satisfies it with a GLV-decomposed addition +/// chain internally (`intro_endo`'s in-circuit `phi(G)`/`phi(Q)` certs, no untrusted advice) -- +/// this proves those claims the deferred state above only checked structurally, then verifies the +/// resulting STARK proof against the same root the main VM committed. #[test] -fn core_ecdsa_k256_keccak_verify_accepts_generator_public_key() { - let fixture = generator_public_key_fixture(); +fn core_ecdsa_k256_keccak_verify_glv_claim_proves_and_verifies() { + let fixture = valid_fixture(); + let output = run_verify(&fixture).expect("valid core ECDSA K256/Keccak signature must verify"); - let output = run_verify(&fixture).expect("generator public key must verify"); - assert_deferred_state_round_trips(&output); + let proof = prove_deferred_state(&output.deferred_state, HashFunction::Blake3_256) + .expect("the GLV-decomposed deferred claims must be provable"); + let verified_root = + verify_deferred(&proof).expect("the GLV-decomposed deferred proof must verify"); + assert_eq!( + verified_root, + output.deferred_state.root(), + "verified root must match the root the main VM committed", + ); +} + +/// A public key whose x-coordinate is `G_x`, `beta*G_x`, or `beta^2*G_x` puts it on the secp256k1 +/// GLV endomorphism's orbit of the generator -- `Q` coincides with `G`, `phi(G)`, or `phi^2(G)` as +/// a point value. `verify` logs the same plain `u1*G + u2*Q` claim regardless; on the prover side, +/// `intro_endo`'s in-circuit value relation means a coincidence like this is ordinary point-store +/// dedup, not a forgery surface, so it needs no special-casing -- each such key must verify (and +/// prove) like any other. +/// +/// Their discrete logs are `1`, `lambda`, `lambda^2` and the negations thereof, so no such key has +/// practical use. They are valid curve points all the same, and a verifier that traps on a valid +/// key decides it by accident rather than on the signature. +#[test] +fn core_ecdsa_k256_keccak_verify_accepts_glv_base_repeating_public_keys() { + let one = core::array::from_fn(|i| u32::from(i == 0)); + let lambda_squared = scalar_mul_mod_n(SECP256K1_LAMBDA, SECP256K1_LAMBDA); + + for (name, secret_scalar) in [ + ("Q == G", one), + ("Q == -G", negate_scalar_mod_n(one)), + ("Q == phi(G)", SECP256K1_LAMBDA), + ("Q == phi^2(G)", lambda_squared), + ] { + let sk = SigningKey::read_from_bytes(&le_limbs_to_be_bytes(secret_scalar)) + .unwrap_or_else(|_| panic!("{name}: the secret scalar must be a valid key")); + let fixture = fixture_from_signing_key(sk); + + let output = run_verify(&fixture) + .unwrap_or_else(|e| panic!("{name} must verify through the 2-base fallback: {e}")); + + let proof = prove_deferred_state(&output.deferred_state, HashFunction::Blake3_256) + .unwrap_or_else(|_| panic!("{name}: the fallback's deferred claims must be provable")); + let verified_root = verify_deferred(&proof) + .unwrap_or_else(|_| panic!("{name}: the fallback's deferred proof must verify")); + assert_eq!(verified_root, output.deferred_state.root(), "{name}"); + } } #[test] @@ -63,7 +113,7 @@ fn core_ecdsa_k256_keccak_verify_accepts_high_s_untrusted_witness() { "miden-crypto Rust verification must reject high-s", ); - set_s(&mut fixture, high_s); + fixture.advice = ecdsa_k256_keccak::encode_signature(&fixture.public_key, &high_s_signature); run_verify(&fixture) .expect("high-s remains an equivalent witness when signature advice is not committed"); } @@ -170,17 +220,12 @@ struct Fixture { } fn valid_fixture() -> Fixture { - let mut rng = ChaCha20Rng::from_seed([0xe5; 32]); - let sk = SigningKey::with_rng(&mut rng); - fixture_from_signing_key(sk) + fixture_from_signing_key(default_signing_key()) } -fn generator_public_key_fixture() -> Fixture { - let mut secret_key_bytes = [0u8; 32]; - secret_key_bytes[31] = 1; - let sk = SigningKey::read_from_bytes(&secret_key_bytes).expect("scalar 1 is a valid key"); - - fixture_from_signing_key(sk) +fn default_signing_key() -> SigningKey { + let mut rng = ChaCha20Rng::from_seed([0xe5; 32]); + SigningKey::with_rng(&mut rng) } fn fixture_from_signing_key(sk: SigningKey) -> Fixture { diff --git a/precompiles-prover/src/deferred/session.rs b/precompiles-prover/src/deferred/session.rs index 4a8d542ee6..32daac3a5b 100644 --- a/precompiles-prover/src/deferred/session.rs +++ b/precompiles-prover/src/deferred/session.rs @@ -1,4 +1,7 @@ -use alloc::{collections::BTreeSet, vec::Vec}; +use alloc::{ + collections::{BTreeMap, BTreeSet, btree_map::Entry}, + vec::Vec, +}; use miden_core::deferred::{DataChunk, DeferredState, Digest, Node, TRUE_DIGEST, Tag}; use miden_precompiles::{ @@ -7,17 +10,19 @@ use miden_precompiles::{ }; use crate::{ + ec::trace::EcPointPtr, math::{U256, from_limbs32}, session::{EcNode, Session, Truthy, UintNode, strategies}, transcript::poseidon2::P2Digest, }; -/// wNAF window for [`translate_ec_msm`](DeferredSessionBuilder::translate_ec_msm)'s -/// joint-wNAF addition chain. `w = 5` (digits odd, `|d| < 2^{w-1}`, `2^{w-2}` -/// odd multiples per base) matches the width already used for full-width -/// (~256-bit) scalars elsewhere in this crate (`examples/ec_msm_ecdsa.rs`'s -/// `WNAF_W`) — GLV's `w = 4` sweet spot is tuned for its ~128-bit halves, not -/// the full-width scalars a raw MSM claim carries. +/// wNAF window for [`translate_ec_msm`](DeferredSessionBuilder::translate_ec_msm)'s joint-wNAF +/// addition chain (digits odd, `|d| < 2^{w-1}`, `2^{w-2}` odd multiples per base). A smaller window +/// suits GLV's ~128-bit halves in isolation, but `translate_ec_msm` now caches a repeating base's +/// table across the whole batch ([`Self::wnaf_tables`](DeferredSessionBuilder::wnaf_tables)), which +/// makes the one-time table-build cost a wash and leaves the ladder's per-signature digit density +/// as the dominant recurring cost — `w = 5` keeps that density low for both the classic 2-base MSM +/// and GLV's 4-base one. const MSM_WNAF_WINDOW: usize = 5; pub(crate) struct DeferredSession { @@ -49,7 +54,12 @@ pub(crate) enum DeferredSessionError { pub(crate) fn session_from_deferred_state( state: &DeferredState, ) -> Result { - let mut builder = DeferredSessionBuilder { state, session: Session::new() }; + let mut builder = DeferredSessionBuilder { + state, + session: Session::new(), + wnaf_tables: BTreeMap::new(), + glv_endo_tables: BTreeMap::new(), + }; let root = builder.translate_truthy(state.root())?; let expected = P2Digest::from(state.root()); @@ -66,6 +76,17 @@ pub(crate) fn session_from_deferred_state( struct DeferredSessionBuilder<'a> { state: &'a DeferredState, session: Session, + /// A base's plain [`WnafTable`](strategies::WnafTable) (`⟨P×1⟩`), by + /// `(point, window)` — so a base recurring across many MSM claims in this + /// pass (the ECDSA generator across a batch of signatures) lays its + /// table once and every claim that rides it reuses the same one. + wnaf_tables: BTreeMap<(EcPointPtr, usize), strategies::WnafTable>, + /// A base's GLV endomorphism [`WnafTable`](strategies::WnafTable) + /// (`⟨P×λ⟩`), cached the same way as [`Self::wnaf_tables`] — both tables + /// are built positive-only (see [`strategies::wnaf_table_endo`]), so a + /// recurring base's tables are shared across every claim on it + /// regardless of each claim's GLV split signs. + glv_endo_tables: BTreeMap<(EcPointPtr, usize), strategies::WnafTable>, } #[derive(Debug, Clone, Copy)] @@ -291,7 +312,46 @@ impl<'a> DeferredSessionBuilder<'a> { // `joint_wnaf`'s per-column cost is linear in the term count (unlike // Straus's 2^k subset-sum table), so an arbitrary-arity pair-list // never needs a term-count cap here. - let expr = strategies::joint_wnaf(&mut self.session, &expr_terms, MSM_WNAF_WINDOW); + // + // GLV curves split each term's scalar in half (`glv_joint_wnaf`), + // trading ~half the ladder height for twice the virtual bases — + // `msm_combine`'s shared-base merge folds each pair's plain/endo + // legs back onto the caller's original term, so the claim below is + // unaffected either way. Both tables are cached per `(point, + // window)` the same way the plain path's are (a recurring base — + // the ECDSA generator across a batch of signatures — lays each + // table once); sign rides the digit selection inside + // `glv_joint_wnaf_with_tables`, not the table's seed, so a shared + // base's tables serve every claim's GLV split regardless of sign. + let expr = if !curve.endomorphisms().is_empty() { + for (base, _) in &expr_terms { + self.ensure_wnaf_table(base, MSM_WNAF_WINDOW); + self.ensure_wnaf_table_endo(base, MSM_WNAF_WINDOW); + } + let table_terms: Vec<(&strategies::WnafTable, &strategies::WnafTable, U256)> = + expr_terms + .iter() + .map(|(base, scalar)| { + ( + self.wnaf_tables.get(&(base.point, MSM_WNAF_WINDOW)).unwrap(), + self.glv_endo_tables.get(&(base.point, MSM_WNAF_WINDOW)).unwrap(), + *scalar, + ) + }) + .collect(); + strategies::glv_joint_wnaf_with_tables(&mut self.session, &table_terms) + } else { + for (base, _) in &expr_terms { + self.ensure_wnaf_table(base, MSM_WNAF_WINDOW); + } + let table_terms: Vec<(&strategies::WnafTable, U256)> = expr_terms + .iter() + .map(|(base, scalar)| { + (self.wnaf_tables.get(&(base.point, MSM_WNAF_WINDOW)).unwrap(), *scalar) + }) + .collect(); + strategies::joint_wnaf_with_tables(&mut self.session, &table_terms) + }; let claim_terms = terms .iter() @@ -300,6 +360,26 @@ impl<'a> DeferredSessionBuilder<'a> { Ok(self.session.ec_msm(expr, &claim_terms)) } + /// Ensures `base`'s [`WnafTable`](strategies::WnafTable) at window `w` is + /// in [`Self::wnaf_tables`], building it once via + /// [`wnaf_table`](strategies::wnaf_table) on the first request and + /// reusing it for every later claim that rides the same base. + fn ensure_wnaf_table(&mut self, base: &EcNode, w: usize) { + if let Entry::Vacant(entry) = self.wnaf_tables.entry((base.point, w)) { + entry.insert(strategies::wnaf_table(&mut self.session, base, w)); + } + } + + /// [`Self::ensure_wnaf_table`]'s GLV endomorphism-leg twin: ensures + /// `base`'s endomorphism [`WnafTable`](strategies::WnafTable) at window + /// `w` is in [`Self::glv_endo_tables`], building it once via + /// [`wnaf_table_endo`](strategies::wnaf_table_endo). + fn ensure_wnaf_table_endo(&mut self, base: &EcNode, w: usize) { + if let Entry::Vacant(entry) = self.glv_endo_tables.entry((base.point, w)) { + entry.insert(strategies::wnaf_table_endo(&mut self.session, base, w)); + } + } + fn require_truthy_metadata(&self, digest: Digest) -> Result<(), DeferredSessionError> { let (canonical_digest, canonical_node) = self .state diff --git a/precompiles-prover/src/ec/add/mod.rs b/precompiles-prover/src/ec/add/mod.rs index 38a650b5cc..fe5c48da7f 100644 --- a/precompiles-prover/src/ec/add/mod.rs +++ b/precompiles-prover/src/ec/add/mod.rs @@ -218,7 +218,12 @@ pub const COL_RP_HI: usize = 18; /// Limbs of `r_ptr − q_ptr − 1` — proving `r_ptr > q_ptr`. pub const COL_RQ_LO: usize = 19; pub const COL_RQ_HI: usize = 20; -pub const NUM_MAIN_COLS: usize = 21; +/// The group's GLV endomorphism `β`/`λ` ptrs (carried only to close the +/// `EcGroup` consume; the none-sentinel 0 for a group with no +/// endomorphism). +pub const COL_BETA_PTR: usize = 21; +pub const COL_LAMBDA_PTR: usize = 22; +pub const NUM_MAIN_COLS: usize = 23; /// Block period: one add op = 4 rows. pub const PERIOD: usize = 4; @@ -468,6 +473,8 @@ where let a_ptr: LB::Expr = local[COL_A_PTR].into(); let b_ptr: LB::Expr = local[COL_B_PTR].into(); let bound: LB::Expr = local[COL_BOUND_PTR].into(); + let beta_ptr: LB::Expr = local[COL_BETA_PTR].into(); + let lambda_ptr: LB::Expr = local[COL_LAMBDA_PTR].into(); let pai_p: LB::Expr = local[COL_PAI_P].into(); let pai_q: LB::Expr = local[COL_PAI_Q].into(); let cancel: LB::Expr = local[COL_CANCEL].into(); @@ -614,6 +621,8 @@ where b_ptr: b_ptr.clone(), bound_ptr: bound.clone(), scalar_bound_ptr: sbound, + beta_ptr: beta_ptr.clone(), + lambda_ptr: lambda_ptr.clone(), }, f2 ), diff --git a/precompiles-prover/src/ec/add/trace.rs b/precompiles-prover/src/ec/add/trace.rs index 0ada006afe..af751de910 100644 --- a/precompiles-prover/src/ec/add/trace.rs +++ b/precompiles-prover/src/ec/add/trace.rs @@ -13,10 +13,11 @@ use alloc::{collections::BTreeMap, vec::Vec}; use miden_core::{Felt, field::QuadFelt, utils::RowMajorMatrix}; use super::{ - CELL_GROUP, CELL_R, CELL_SBOUND, COL_A_PTR, COL_ACT, COL_B_PTR, COL_BOUND_PTR, COL_CANCEL, - COL_DBL, COL_GEN, COL_MINTS, COL_PAI_P, COL_PAI_Q, COL_PX, COL_PY, COL_QX, COL_QY, COL_RP_HI, - COL_RP_LO, COL_RQ_HI, COL_RQ_LO, EcGroupAddAir, NUM_CELLS, NUM_MAIN_COLS, PERIOD, ROW_RES, - ROW_SLOPE, ROW_TAIL, ROW_TERM, TERM_CELL_MULT, TERM_CELL_P, TERM_CELL_Q, + CELL_GROUP, CELL_R, CELL_SBOUND, COL_A_PTR, COL_ACT, COL_B_PTR, COL_BETA_PTR, COL_BOUND_PTR, + COL_CANCEL, COL_DBL, COL_GEN, COL_LAMBDA_PTR, COL_MINTS, COL_PAI_P, COL_PAI_Q, COL_PX, COL_PY, + COL_QX, COL_QY, COL_RP_HI, COL_RP_LO, COL_RQ_HI, COL_RQ_LO, EcGroupAddAir, NUM_CELLS, + NUM_MAIN_COLS, PERIOD, ROW_RES, ROW_SLOPE, ROW_TAIL, ROW_TERM, TERM_CELL_MULT, TERM_CELL_P, + TERM_CELL_Q, }; use crate::{ ec::trace::{EcGroupPtr, EcPointPtr, EcStoreRequires}, @@ -74,6 +75,11 @@ pub(crate) struct EcAddOp { pub bound: UintPtr, pub a: UintPtr, pub b: UintPtr, + /// The group's GLV endomorphism `β`/`λ` ptrs (carried only to close + /// the `EcGroup` consume; the none-sentinel 0 for a group with no + /// endomorphism). + pub beta: UintPtr, + pub lambda: UintPtr, pub p: EcPointPtr, pub q: EcPointPtr, pub r: EcPointPtr, @@ -189,6 +195,8 @@ fn op_block(op: &EcAddOp, mult: ProvideMult, ec: &EcStoreRequires) -> Vec set(row, COL_A_PTR, op.a.addr()); set(row, COL_B_PTR, op.b.addr()); set(row, COL_BOUND_PTR, op.bound.addr()); + set(row, COL_BETA_PTR, op.beta.addr()); + set(row, COL_LAMBDA_PTR, op.lambda.addr()); set(row, COL_PAI_P, u32::from(pai_p)); set(row, COL_PAI_Q, u32::from(pai_q)); set(row, COL_CANCEL, u32::from(cancel)); diff --git a/precompiles-prover/src/ec/groups.rs b/precompiles-prover/src/ec/groups.rs index f7f54dc33d..78a2a13390 100644 --- a/precompiles-prover/src/ec/groups.rs +++ b/precompiles-prover/src/ec/groups.rs @@ -71,7 +71,13 @@ pub const COL_SBOUND_PTR: usize = 4; /// group + every live-case add op); 0 on pad rows — the only liveness /// signal this chiplet needs. pub const COL_MULT: usize = 5; -pub const NUM_MAIN_COLS: usize = 6; +/// The GLV endomorphism base-field constant `β`'s uint ptr (the +/// none-sentinel 0 for a group with no endomorphism). +pub const COL_BETA_PTR: usize = 6; +/// The GLV endomorphism scalar `λ`'s uint ptr (the none-sentinel 0 for a +/// group with no endomorphism). +pub const COL_LAMBDA_PTR: usize = 7; +pub const NUM_MAIN_COLS: usize = 8; // Aux: the single LogUp running-sum column (one fraction). const NUM_LOGUP_COLS: usize = 1; @@ -188,6 +194,8 @@ where b_ptr: local[COL_B_PTR].into(), bound_ptr: local[COL_BOUND_PTR].into(), scalar_bound_ptr: local[COL_SBOUND_PTR].into(), + beta_ptr: local[COL_BETA_PTR].into(), + lambda_ptr: local[COL_LAMBDA_PTR].into(), }, provide_deg, ); diff --git a/precompiles-prover/src/ec/mod.rs b/precompiles-prover/src/ec/mod.rs index 055ef732c7..542a55abb9 100644 --- a/precompiles-prover/src/ec/mod.rs +++ b/precompiles-prover/src/ec/mod.rs @@ -81,11 +81,13 @@ use crate::{ // ================================================================================================ /// LogUp message for the [`EcGroup`](BusId::EcGroup) relation: the -/// 5-tuple `(group_ptr, a_ptr, b_ptr, bound_ptr, scalar_bound_ptr)` -/// binding a short-Weierstrass group to its curve context — the params -/// (stored uints sharing `bound_ptr`, which fixes the base field) plus -/// the scalar-field modulus handle (= `bound_ptr` while nothing -/// constrains it; see [`groups`]). +/// 7-tuple `(group_ptr, a_ptr, b_ptr, bound_ptr, scalar_bound_ptr, +/// beta_ptr, lambda_ptr)` binding a short-Weierstrass group to its curve +/// context — the params (stored uints sharing `bound_ptr`, which fixes +/// the base field) plus the scalar-field modulus handle (= `bound_ptr` +/// while nothing constrains it; see [`groups`]) plus the GLV +/// endomorphism params `β`/`λ` (the none-sentinel 0 for a group with no +/// endomorphism). #[derive(Debug, Clone)] pub struct EcGroupMsg { pub group_ptr: E, @@ -93,6 +95,8 @@ pub struct EcGroupMsg { pub b_ptr: E, pub bound_ptr: E, pub scalar_bound_ptr: E, + pub beta_ptr: E, + pub lambda_ptr: E, } impl LookupMessage for EcGroupMsg @@ -109,6 +113,8 @@ where self.b_ptr.clone(), self.bound_ptr.clone(), self.scalar_bound_ptr.clone(), + self.beta_ptr.clone(), + self.lambda_ptr.clone(), ], ) } @@ -183,7 +189,12 @@ pub const COL_ACT: usize = 12; /// by its minting `EcGroupAdd` op) *instead of* the MAC trio. Mutually /// exclusive with `is_pai`; the trio gate drops on these rows. pub const COL_IS_CERT: usize = 13; -pub const NUM_MAIN_COLS: usize = 14; +/// The group's GLV endomorphism `β`/`λ` ptrs (carried only to close the +/// `EcGroup` consume; the none-sentinel 0 for a group with no +/// endomorphism). +pub const COL_BETA_PTR: usize = 14; +pub const COL_LAMBDA_PTR: usize = 15; +pub const NUM_MAIN_COLS: usize = 16; // Aux: five columns, flattened via `frac_col!` so every closing // constraint stays at degree ≤ 3 → `log_quotient_degree = 1`. Six @@ -329,6 +340,8 @@ where let is_pai: LB::Expr = local[COL_IS_PAI].into(); let is_cert: LB::Expr = local[COL_IS_CERT].into(); let act: LB::Expr = local[COL_ACT].into(); + let beta_ptr: LB::Expr = local[COL_BETA_PTR].into(); + let lambda_ptr: LB::Expr = local[COL_LAMBDA_PTR].into(); // Pads zero the mult cell, so the provide needs no act gate; the // consumes do (an all-zero pad row must touch no bus). The trio fires @@ -385,6 +398,8 @@ where b_ptr: b_ptr.clone(), bound_ptr: bound_ptr.clone(), scalar_bound_ptr: sbound_ptr.clone(), + beta_ptr: beta_ptr.clone(), + lambda_ptr: lambda_ptr.clone(), }, consume_deg ), diff --git a/precompiles-prover/src/ec/msm/mod.rs b/precompiles-prover/src/ec/msm/mod.rs index 096976a391..85575971a5 100644 --- a/precompiles-prover/src/ec/msm/mod.rs +++ b/precompiles-prover/src/ec/msm/mod.rs @@ -52,7 +52,7 @@ use crate::{ }, primitives::byte_pair_lut::Range16Msg, relations::{BusId, MAX_MESSAGE_WIDTH, NUM_BUS_IDS}, - uint::{UintValMsg, add::UintAddMsg}, + uint::{UintValMsg, add::UintAddMsg, mul::UintMulMsg}, utils::{current_main, next_main}, }; @@ -251,9 +251,36 @@ pub const COL_NEG_YR: usize = 36; /// the `EcOnCurveCert(group, R)` provide that vouches R's (trio-free) /// membership — R on-curve because `val_a` is. pub const COL_NEG_MINTED: usize = 37; -pub const NUM_MAIN_COLS: usize = 38; - -// Aux: 11 columns, flattened via `frac_col!` over the 20 fractions so +/// The group's GLV endomorphism `β`/`λ` ptrs (carried only to close the +/// boundary's `EcGroup` consume; the none-sentinel 0 for a group with no +/// endomorphism). Combine/neg-boundary-only, like [`COL_A_PTR`] / +/// [`COL_B_PTR`] / [`COL_BOUND_PTR`]. +pub const COL_BETA_PTR: usize = 38; +pub const COL_LAMBDA_PTR: usize = 39; + +// --- intro_endo-only columns (0 on intro / combine / neg / pad rows) -- +/// Op-family flag for `intro_endo` — the fourth one-hot member (`is_intro + +/// is_intro_endo + is_combine + is_neg = act`). An `intro_endo(φ(P), P)` is +/// a 1-row run recording the term `⟨P × λ⟩` with value `φ(P)` — GLV's +/// endomorphism leaf, mirroring plain `intro`'s `⟨P × 1⟩` / `val = P` but +/// with the value relation `x_φ = β·x_P`, `y_φ = y_P` in place of a ptr +/// equality (see [`msm::require::intro_endo`](crate::ec::msm::require::intro_endo)). +pub const COL_IS_INTRO_ENDO: usize = 40; +/// `P`'s x ptr (from the `EcPoint(base)` consume). +pub const COL_ENDO_BASE_X: usize = 41; +/// The shared y ptr: both the `EcPoint(base)` and `EcPoint(val)` consumes +/// carry it, so they pin `y_φ = y_P` for free. +pub const COL_ENDO_Y: usize = 42; +/// `φ(P)`'s x ptr (from the `EcPoint(val)` consume; tied to `β·x_P` by the +/// `UintMul` consume). +pub const COL_ENDO_VAL_X: usize = 43; +/// Mint flag: 1 iff this row freshly mints `φ(P)`, gating the +/// `EcOnCurveCert(group, val)` provide that vouches its (trio-free) +/// membership — `φ(P)` on-curve because `P` is. +pub const COL_ENDO_MINTED: usize = 44; +pub const NUM_MAIN_COLS: usize = 45; + +// Aux: 13 columns, flattened via `frac_col!` over the 24 fractions so // every closing constraint stays at degree ≤ 3 → `log_quotient_degree` = // 1 (folding the intermediate 12-column flatten and the follow-on // singleton-pack into one step): @@ -265,12 +292,14 @@ pub const NUM_MAIN_COLS: usize = 38; // col 5: UintAdd (neg scalar) + UintAdd (neg value y-flip). // col 6: MsmExpr consume A (head) + MsmExpr consume B (head). // col 7: EcGroupAdd (combine value) + EcPoint(val_a) (neg value coord). -// col 8: EcPoint(R) (neg value coord) + EcGroup (sbound pin). +// col 8: EcPoint(R) (neg value coord) + EcGroup (sbound pin — combine, neg, and intro_endo). // col 9: ordering Range16 — a_lo + a_hi. // col 10: ordering Range16 — b_lo + b_hi. -const NUM_LOGUP_COLS: usize = 11; -const AUX_WIDTH: usize = 11; -const COLUMN_SHAPE: [usize; NUM_LOGUP_COLS] = [1, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2]; +// col 11: EcPoint(base) (intro_endo coord) + EcPoint(val) (intro_endo coord) — the shared-y tie. +// col 12: UintMul (intro_endo's x_φ = β·x_P) + EcOnCurveCert provide (intro_endo value). +const NUM_LOGUP_COLS: usize = 13; +const AUX_WIDTH: usize = 13; +const COLUMN_SHAPE: [usize; NUM_LOGUP_COLS] = [1, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2]; /// `2¹⁶`, the high-half weight in the ordering decomposition. const TWO16: u32 = 1 << 16; @@ -321,15 +350,18 @@ impl LiftedAir for EcMsmAir { let act_next: AB::Expr = next[COL_ACT].into(); let is_boundary: AB::Expr = local[COL_IS_BOUNDARY].into(); let is_intro: AB::Expr = local[COL_IS_INTRO].into(); + let is_intro_endo: AB::Expr = local[COL_IS_INTRO_ENDO].into(); let is_combine: AB::Expr = local[COL_IS_COMBINE].into(); let is_neg: AB::Expr = local[COL_IS_NEG].into(); let neg_minted: AB::Expr = local[COL_NEG_MINTED].into(); + let endo_minted: AB::Expr = local[COL_ENDO_MINTED].into(); let idx: AB::Expr = local[COL_IDX].into(); // Booleans. builder.assert_bool(local[COL_ACT]); builder.assert_bool(local[COL_IS_BOUNDARY]); builder.assert_bool(local[COL_IS_INTRO]); + builder.assert_bool(local[COL_IS_INTRO_ENDO]); builder.assert_bool(local[COL_IS_COMBINE]); builder.assert_bool(local[COL_IS_NEG]); // The neg-value mint flag is boolean and lives only on neg rows — so a @@ -337,12 +369,18 @@ impl LiftedAir for EcMsmAir { // (the provide is gated `−neg_minted · is_boundary`). builder.assert_bool(local[COL_NEG_MINTED]); builder.assert_zero((AB::Expr::ONE - is_neg.clone()) * neg_minted); + // Likewise the intro_endo value mint flag. + builder.assert_bool(local[COL_ENDO_MINTED]); + builder.assert_zero((AB::Expr::ONE - is_intro_endo.clone()) * endo_minted); // Activity is sticky-downward (pads are a tail), and the op-family // one-hot sums to `act` (so every active row is exactly one op and // pads are no op). builder.when_transition().assert_zero((AB::Expr::ONE - act.clone()) * act_next); - builder.assert_zero(is_intro.clone() + is_combine.clone() + is_neg.clone() - act.clone()); + builder.assert_zero( + is_intro.clone() + is_intro_endo.clone() + is_combine.clone() + is_neg.clone() + - act.clone(), + ); // A boundary only on active rows; pads carry `is_boundary = 0` so // the allocator freezes `expr_ptr` across the tail. builder.assert_zero((AB::Expr::ONE - act.clone()) * is_boundary.clone()); @@ -382,6 +420,7 @@ impl LiftedAir for EcMsmAir { COL_MULT, COL_CLAIM_MULT, COL_IS_INTRO, + COL_IS_INTRO_ENDO, COL_IS_COMBINE, COL_IS_NEG, COL_A_EXPR, @@ -391,6 +430,8 @@ impl LiftedAir for EcMsmAir { COL_A_PTR, COL_B_PTR, COL_BOUND_PTR, + COL_BETA_PTR, + COL_LAMBDA_PTR, ] { let here: AB::Expr = local[col].into(); let there: AB::Expr = next[col].into(); @@ -405,6 +446,17 @@ impl LiftedAir for EcMsmAir { let val: AB::Expr = local[COL_VAL].into(); builder.assert_zero(is_intro * (val - base)); + // intro_endo: also a 1-row run (boundary). The value relation is + // *not* `val = base` (that's plain intro's) — it's proved by the + // LogUp coordinate relation below (§ intro_endo). The only native + // constraint here is the scalar pin: the term's scalar is the + // group's λ, never an AIR-known constant (see + // [`msm::require::intro_endo`](crate::ec::msm::require::intro_endo)). + builder.assert_zero(is_intro_endo.clone() * (AB::Expr::ONE - is_boundary.clone())); + let lambda_ptr: AB::Expr = local[COL_LAMBDA_PTR].into(); + let scalar: AB::Expr = local[COL_SCALAR].into(); + builder.assert_zero(is_intro_endo * (scalar - lambda_ptr)); + // ---- combine ---------------------------------------------------- // Per-row take one-hot: each combine row emits one output term. let take_a: AB::Expr = local[COL_TAKE_A].into(); @@ -514,6 +566,7 @@ where let neg_claim_mult: LB::Expr = LB::Expr::ZERO - local[COL_CLAIM_MULT].into(); let is_boundary: LB::Expr = local[COL_IS_BOUNDARY].into(); let is_intro: LB::Expr = local[COL_IS_INTRO].into(); + let is_intro_endo: LB::Expr = local[COL_IS_INTRO_ENDO].into(); let is_combine: LB::Expr = local[COL_IS_COMBINE].into(); let is_neg: LB::Expr = local[COL_IS_NEG].into(); @@ -541,6 +594,8 @@ where let a_ptr: LB::Expr = local[COL_A_PTR].into(); let b_ptr: LB::Expr = local[COL_B_PTR].into(); let bound_ptr: LB::Expr = local[COL_BOUND_PTR].into(); + let beta_ptr: LB::Expr = local[COL_BETA_PTR].into(); + let lambda_ptr: LB::Expr = local[COL_LAMBDA_PTR].into(); let a_lo: LB::Expr = local[COL_A_DIFF_LO].into(); let a_hi: LB::Expr = local[COL_A_DIFF_HI].into(); let b_lo: LB::Expr = local[COL_B_DIFF_LO].into(); @@ -551,18 +606,29 @@ where let neg_ya: LB::Expr = local[COL_NEG_YA].into(); let neg_yr: LB::Expr = local[COL_NEG_YR].into(); let neg_minted: LB::Expr = local[COL_NEG_MINTED].into(); + // intro_endo coordinate cells (always the boundary — intro_endo is + // a 1-row run): P's x, the shared y (P.y = φ(P).y), φ(P)'s x, and + // the mint flag gating φ(P)'s cert. + let endo_base_x: LB::Expr = local[COL_ENDO_BASE_X].into(); + let endo_y: LB::Expr = local[COL_ENDO_Y].into(); + let endo_val_x: LB::Expr = local[COL_ENDO_VAL_X].into(); + let endo_minted: LB::Expr = local[COL_ENDO_MINTED].into(); // Cursor advances (= the MsmTerm consume gates) and the boundary // gates for expression-level traffic. A neg advances cursor i every // row (no take flags), so `adv_i` carries `is_neg`. The a-side - // boundary traffic (operand-A head, ordering, the `EcGroup` pin) - // fires for combine AND neg; the b-side and the combine value for - // combine only; the neg value for neg only. + // boundary traffic (operand-A head, ordering) fires for combine AND + // neg; the b-side and the combine value for combine only; the neg + // value for neg only. `bnd_group` is the wider `EcGroup`-pin gate: + // combine, neg, AND intro_endo (which has no operand head / ordering + // of its own, only the group pin authenticating β/λ). let adv_i = take_a + take_both.clone() + is_neg.clone(); let adv_j = take_b + take_both.clone(); let bnd_a = (is_combine.clone() + is_neg.clone()) * is_boundary.clone(); - let bnd_b = is_combine * is_boundary.clone(); + let bnd_b = is_combine.clone() * is_boundary.clone(); let bnd_neg = is_neg.clone() * is_boundary.clone(); + let bnd_group = (is_combine + is_neg.clone() + is_intro_endo.clone()) * is_boundary.clone(); + let bnd_endo = is_intro_endo * is_boundary.clone(); let one_deg = Deg { v: 1, u: 1 }; let two_deg = Deg { v: 2, u: 1 }; @@ -825,13 +891,15 @@ where ), ( "consume-ecgroup", - bnd_a.clone(), + bnd_group, EcGroupMsg { group_ptr: group_ptr.clone(), a_ptr: a_ptr.clone(), b_ptr: b_ptr.clone(), bound_ptr: bound_ptr.clone(), scalar_bound_ptr: sbound_ptr.clone(), + beta_ptr: beta_ptr.clone(), + lambda_ptr: lambda_ptr.clone(), }, two_deg ), @@ -854,5 +922,69 @@ where ("range-b-lo", bnd_b.clone(), Range16Msg { w: b_lo }, two_deg), ("range-b-hi", bnd_b, Range16Msg { w: b_hi }, two_deg), ); + + // col 11 (paired, lqd-1): intro_endo's coordinate relation — `P`'s + // and `φ(P)`'s `EcPoint` consumes, sharing `endo_y` so the store's + // provides pin `y_φ = y_P` for free (mirroring `neg`'s shared-x + // trick, transposed to the shared coordinate GLV needs). + frac_col!( + builder, + "ec-msm-endo", + pair_deg, + ( + "consume-ecpoint-endo-base", + bnd_endo.clone(), + EcPointMsg { + point_ptr: base.clone(), + group_ptr: group_ptr.clone(), + x_ptr: endo_base_x.clone(), + y_ptr: endo_y.clone(), + is_pai: LB::Expr::ZERO, + }, + two_deg + ), + ( + "consume-ecpoint-endo-val", + bnd_endo.clone(), + EcPointMsg { + point_ptr: val.clone(), + group_ptr: group_ptr.clone(), + x_ptr: endo_val_x.clone(), + y_ptr: endo_y, + is_pai: LB::Expr::ZERO, + }, + two_deg + ), + ); + // col 12 (paired, lqd-1): the value relation's only genuinely new + // certificate — `x_φ = β·x_P` — paired with the on-curve cert + // provide for a freshly-minted `φ(P)` (φ(P) on-curve because P is, + // same closure-cert idiom `neg` uses for its value). + frac_col!( + builder, + "ec-msm-endo", + pair_deg, + ( + "consume-uintmul-endo", + bnd_endo, + UintMulMsg { + kappa_a: LB::Expr::ONE, + kappa_c: LB::Expr::ZERO, + a_ptr: beta_ptr, + b_ptr: endo_base_x, + c_ptr: bound_ptr.clone(), + r_ptr: endo_val_x, + bound_ptr, + is_sub: LB::Expr::ZERO, + }, + two_deg + ), + ( + "provide-oncurvecert-endo", + LB::Expr::ZERO - endo_minted, + EcOnCurveCertMsg { group_ptr, r_ptr: val }, + two_deg + ), + ); } } diff --git a/precompiles-prover/src/ec/msm/require.rs b/precompiles-prover/src/ec/msm/require.rs index 1f87b2ac7b..97eec999ec 100644 --- a/precompiles-prover/src/ec/msm/require.rs +++ b/precompiles-prover/src/ec/msm/require.rs @@ -40,6 +40,48 @@ pub fn intro( msm.intro(group, sbound, base, one) } +/// Promote a stored point `P` to the 1-term MSM expression `⟨P × λ⟩` +/// (value `= φ(P)`) — GLV's endomorphism leaf, the second base a joint +/// wNAF ladder walks alongside [`intro`]'s `⟨P × 1⟩` (the two merge back +/// onto one term at `combine` time: `msm_combine`'s shared-base rule +/// gives `⟨P × (a + b·λ)⟩`). `λ` is never an AIR-known constant — the +/// term's scalar is the group's own `lambda_ptr`, authenticated by the +/// boundary's `EcGroup` consume — and `φ(P)`'s membership rides its +/// value relation (`x_φ = β·x_P`, `y_φ = y_P`, both certified in-circuit) +/// rather than a fresh MAC trio, so this never revalidates `P`. Panics if +/// `base` is the point at infinity or its group has no GLV endomorphism. +/// Returns the expression handle. +pub fn intro_endo( + msm: &mut EcMsmRequires, + ec: &mut EcStores, + uint: &mut UintStores, + base: EcPointPtr, +) -> EcExprPtr { + if let Some(e) = msm.lookup_intro_endo(base) { + return e; // a prior ⟨base × λ⟩ — reuse it + } + let group = ec.store.point_params(base).0; + let (beta_ptr, lambda_ptr) = ec.store.group_glv_params(group); + assert_ne!(beta_ptr.addr(), 0, "intro_endo requires a group with a GLV endomorphism"); + let (a_ptr, b_ptr, bound_ptr) = ec.store.group_params(group); + let sbound = ec.store.group_sbound(group); + let (px, py) = ec.store.point_params(base).1.expect("intro_endo of the point at infinity"); + + // x_φ = β·x_P (the plain `κ_a = 1, κ_c = 0` product arrangement, like + // the membership trio's `u ≡ x² + a`); y_φ = y_P rides the shared + // `endo_y` ptr for free. + let phi_x = uint.require().mac(1, beta_ptr, px, 0, bound_ptr); + let (val, minted) = ec.store.add_point_cert(group, phi_x, py); + ec.store.require_ecpoint(base); + ec.store.require_ecpoint(val); + ec.store.require_ecgroup(group); + + msm.intro_endo( + group, sbound, a_ptr, b_ptr, bound_ptr, beta_ptr, lambda_ptr, base, val, px, py, phi_x, + minted, + ) +} + /// Combine two MSM expressions: union their term multisets (scalars on a /// shared base merge `mod` the scalar bound) and add their values. The /// merge walks both base-ordered term lists ([`merge_terms`]); the value is @@ -63,13 +105,16 @@ pub fn combine( let val_a = msm.value(a); let val_b = msm.value(b); let (a_ptr, b_ptr, bound_ptr) = ec.store.group_params(group); + let (beta_ptr, lambda_ptr) = ec.store.group_glv_params(group); let rows = merge_terms(&a_terms, &b_terms, &mut uint.require()); let val = ec.require(uint.require()).add(val_a, val_b, 1); ec.store.require_ecgroup(group); - let c = msm.combine(group, sbound, a_ptr, b_ptr, bound_ptr, a, b, val_a, val_b, val, rows); + let c = msm.combine( + group, sbound, a_ptr, b_ptr, bound_ptr, beta_ptr, lambda_ptr, a, b, val_a, val_b, val, rows, + ); msm.consume_op(a, 1); msm.consume_op(b, 1); c @@ -94,6 +139,7 @@ pub fn neg( let a_terms = msm.terms(a); let val_a = msm.value(a); let (a_ptr, b_ptr, bound_ptr) = ec.store.group_params(group); + let (beta_ptr, lambda_ptr) = ec.store.group_glv_params(group); // Per term: keep the base, negate the scalar (one UintAdd each). let mut rows = Vec::with_capacity(a_terms.len()); @@ -121,7 +167,8 @@ pub fn neg( ec.store.require_ecgroup(group); let c = msm.neg( - group, sbound, a_ptr, b_ptr, bound_ptr, a, val_a, val, px, py, neg_py, minted, rows, + group, sbound, a_ptr, b_ptr, bound_ptr, beta_ptr, lambda_ptr, a, val_a, val, px, py, + neg_py, minted, rows, ); msm.consume_op(a, 1); c diff --git a/precompiles-prover/src/ec/msm/trace.rs b/precompiles-prover/src/ec/msm/trace.rs index 259de9baa8..5a76e68ca7 100644 --- a/precompiles-prover/src/ec/msm/trace.rs +++ b/precompiles-prover/src/ec/msm/trace.rs @@ -13,11 +13,12 @@ use miden_core::{Felt, field::QuadFelt, utils::RowMajorMatrix}; use super::{ COL_A_DIFF_HI, COL_A_DIFF_LO, COL_A_EXPR, COL_A_PTR, COL_ACT, COL_B_DIFF_HI, COL_B_DIFF_LO, - COL_B_EXPR, COL_B_PTR, COL_BASE, COL_BASE_A, COL_BASE_B, COL_BOUND_PTR, COL_CLAIM_MULT, - COL_EXPR_PTR, COL_GROUP_PTR, COL_I, COL_IDX, COL_IS_BOUNDARY, COL_IS_COMBINE, COL_IS_INTRO, - COL_IS_NEG, COL_J, COL_MULT, COL_NEG_MINTED, COL_NEG_X, COL_NEG_YA, COL_NEG_YR, COL_S_A, - COL_S_B, COL_SBOUND_PTR, COL_SCALAR, COL_TAKE_A, COL_TAKE_B, COL_TAKE_BOTH, COL_VAL, COL_VAL_A, - COL_VAL_B, EcMsmAir, NUM_MAIN_COLS, + COL_B_EXPR, COL_B_PTR, COL_BASE, COL_BASE_A, COL_BASE_B, COL_BETA_PTR, COL_BOUND_PTR, + COL_CLAIM_MULT, COL_ENDO_BASE_X, COL_ENDO_MINTED, COL_ENDO_VAL_X, COL_ENDO_Y, COL_EXPR_PTR, + COL_GROUP_PTR, COL_I, COL_IDX, COL_IS_BOUNDARY, COL_IS_COMBINE, COL_IS_INTRO, + COL_IS_INTRO_ENDO, COL_IS_NEG, COL_J, COL_LAMBDA_PTR, COL_MULT, COL_NEG_MINTED, COL_NEG_X, + COL_NEG_YA, COL_NEG_YR, COL_S_A, COL_S_B, COL_SBOUND_PTR, COL_SCALAR, COL_TAKE_A, COL_TAKE_B, + COL_TAKE_BOTH, COL_VAL, COL_VAL_A, COL_VAL_B, EcMsmAir, NUM_MAIN_COLS, }; use crate::{ ec::trace::{EcGroupPtr, EcPointPtr}, @@ -73,6 +74,7 @@ pub struct NegRow { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ExprKind { Intro, + IntroEndo, Combine, Neg, } @@ -107,6 +109,11 @@ struct ExprRecord { a_ptr: u32, b_ptr: u32, bound_ptr: u32, + // The group's GLV endomorphism `beta`/`lambda` ptrs (0 = no + // endomorphism), carried alongside a_ptr/b_ptr/bound_ptr to close the + // boundary's `EcGroup` consume. + beta_ptr: u32, + lambda_ptr: u32, // neg-only value-negation cells (0 on intro/combine): the shared x ptr // (`val_a.x = val.x`), the two y ptrs (`val_a.y`, `val.y = −val_a.y`), and // whether `val` was freshly minted (gates the on-curve cert provide). @@ -114,6 +121,13 @@ struct ExprRecord { neg_ya: u32, neg_yr: u32, neg_minted: u32, + // intro_endo-only value-relation cells (0 elsewhere): `P`'s x ptr, the + // shared y ptr (`P.y = φ(P).y`), `φ(P)`'s x ptr, and whether `φ(P)` was + // freshly minted (gates the on-curve cert provide). + endo_base_x: u32, + endo_y: u32, + endo_val_x: u32, + endo_minted: u32, rows: Vec, /// **Op** use count — bumped per `combine` / `neg` operand use; drives /// the `MsmTerm` provides + part of `MsmExpr`. @@ -134,6 +148,8 @@ struct ExprRecord { enum DedupKey { /// `⟨base × 1⟩` — keyed by the base point (which fixes group + bound). Intro(u32), + /// `⟨base × λ⟩` — keyed by the base point. + IntroEndo(u32), /// `combine(a, b)` — keyed by the two operand expression ptrs. Combine(u32, u32), /// `neg(a)` — keyed by the operand expression ptr. @@ -192,10 +208,16 @@ impl EcMsmRequires { a_ptr: 0, b_ptr: 0, bound_ptr: 0, + beta_ptr: 0, + lambda_ptr: 0, neg_x: 0, neg_ya: 0, neg_yr: 0, neg_minted: 0, + endo_base_x: 0, + endo_y: 0, + endo_val_x: 0, + endo_minted: 0, rows: vec![RowVals { base: base.addr(), scalar: scalar.addr(), @@ -209,6 +231,71 @@ impl EcMsmRequires { e } + /// The expression a prior `intro_endo(base)` produced, if any — a + /// repeat reuses it instead of laying a second `⟨base × λ⟩`. + pub fn lookup_intro_endo(&self, base: EcPointPtr) -> Option { + self.dedup.get(&DedupKey::IntroEndo(base.addr())).copied() + } + + /// Record an `intro_endo` — `⟨base × λ⟩` with `val = φ(base)`, GLV's + /// endomorphism leaf. `beta_ptr`/`lambda_ptr` are the group's + /// endomorphism params (authenticated by the boundary's `EcGroup` + /// consume); `endo_base_x`/`endo_y`/`endo_val_x` are the value + /// relation's coordinate cells (`x_φ = β·x_P`, `y_φ = y_P` — shared + /// `endo_y`); `minted` says whether this row freshly mints `val`'s + /// on-curve cert. Returns the handle. + #[allow(clippy::too_many_arguments)] + pub fn intro_endo( + &mut self, + group: EcGroupPtr, + sbound: UintPtr, + a_ptr: UintPtr, + b_ptr: UintPtr, + bound_ptr: UintPtr, + beta_ptr: UintPtr, + lambda_ptr: UintPtr, + base: EcPointPtr, + val: EcPointPtr, + endo_base_x: UintPtr, + endo_y: UintPtr, + endo_val_x: UintPtr, + minted: bool, + ) -> EcExprPtr { + self.exprs.push(ExprRecord { + kind: ExprKind::IntroEndo, + group: group.addr(), + sbound: sbound.addr(), + val: val.addr(), + a_expr: 0, + b_expr: 0, + val_a: 0, + val_b: 0, + a_ptr: a_ptr.addr(), + b_ptr: b_ptr.addr(), + bound_ptr: bound_ptr.addr(), + beta_ptr: beta_ptr.addr(), + lambda_ptr: lambda_ptr.addr(), + neg_x: 0, + neg_ya: 0, + neg_yr: 0, + neg_minted: 0, + endo_base_x: endo_base_x.addr(), + endo_y: endo_y.addr(), + endo_val_x: endo_val_x.addr(), + endo_minted: minted as u32, + rows: vec![RowVals { + base: base.addr(), + scalar: lambda_ptr.addr(), + ..RowVals::default() + }], + mult: 0, + claim_mult: 0, + }); + let e = EcExprPtr(self.exprs.len() as u32); + self.dedup.insert(DedupKey::IntroEndo(base.addr()), e); + e + } + /// Record a `combine(a, b) = c` with value `val = val_a + val_b` and /// the precomputed merge walk `rows`. `a_ptr`/`b_ptr`/`bound_ptr` are /// the group's curve params + base modulus (to close the `EcGroup` @@ -223,6 +310,8 @@ impl EcMsmRequires { a_ptr: UintPtr, b_ptr: UintPtr, bound_ptr: UintPtr, + beta_ptr: UintPtr, + lambda_ptr: UintPtr, a_expr: EcExprPtr, b_expr: EcExprPtr, val_a: EcPointPtr, @@ -258,10 +347,16 @@ impl EcMsmRequires { a_ptr: a_ptr.addr(), b_ptr: b_ptr.addr(), bound_ptr: bound_ptr.addr(), + beta_ptr: beta_ptr.addr(), + lambda_ptr: lambda_ptr.addr(), neg_x: 0, neg_ya: 0, neg_yr: 0, neg_minted: 0, + endo_base_x: 0, + endo_y: 0, + endo_val_x: 0, + endo_minted: 0, rows, mult: 0, claim_mult: 0, @@ -291,6 +386,8 @@ impl EcMsmRequires { a_ptr: UintPtr, b_ptr: UintPtr, bound_ptr: UintPtr, + beta_ptr: UintPtr, + lambda_ptr: UintPtr, a_expr: EcExprPtr, val_a: EcPointPtr, val: EcPointPtr, @@ -324,10 +421,16 @@ impl EcMsmRequires { a_ptr: a_ptr.addr(), b_ptr: b_ptr.addr(), bound_ptr: bound_ptr.addr(), + beta_ptr: beta_ptr.addr(), + lambda_ptr: lambda_ptr.addr(), neg_x: neg_x.addr(), neg_ya: neg_ya.addr(), neg_yr: neg_yr.addr(), neg_minted: minted as u32, + endo_base_x: 0, + endo_y: 0, + endo_val_x: 0, + endo_minted: 0, rows, mult: 0, claim_mult: 0, @@ -400,6 +503,7 @@ pub fn generate_trace( let expr_ptr = e_idx as u32 + 1; let k = e.rows.len(); let is_intro = e.kind == ExprKind::Intro; + let is_intro_endo = e.kind == ExprKind::IntroEndo; let is_combine = e.kind == ExprKind::Combine; let is_neg = e.kind == ExprKind::Neg; for (idx, rv) in e.rows.iter().enumerate() { @@ -418,6 +522,7 @@ pub fn generate_trace( set(COL_MULT, e.mult); set(COL_CLAIM_MULT, e.claim_mult); set(COL_IS_INTRO, is_intro as u32); + set(COL_IS_INTRO_ENDO, is_intro_endo as u32); set(COL_IS_COMBINE, is_combine as u32); set(COL_IS_NEG, is_neg as u32); if is_combine { @@ -437,6 +542,8 @@ pub fn generate_trace( set(COL_A_PTR, e.a_ptr); set(COL_B_PTR, e.b_ptr); set(COL_BOUND_PTR, e.bound_ptr); + set(COL_BETA_PTR, e.beta_ptr); + set(COL_LAMBDA_PTR, e.lambda_ptr); if is_boundary { let a_diff = expr_ptr - e.a_expr - 1; let b_diff = expr_ptr - e.b_expr - 1; @@ -461,6 +568,8 @@ pub fn generate_trace( set(COL_A_PTR, e.a_ptr); set(COL_B_PTR, e.b_ptr); set(COL_BOUND_PTR, e.bound_ptr); + set(COL_BETA_PTR, e.beta_ptr); + set(COL_LAMBDA_PTR, e.lambda_ptr); if is_boundary { // The cheap value negation lives on the boundary: the // shared x + the two y ptrs (pinned by the EcPoint consumes @@ -476,6 +585,20 @@ pub fn generate_trace( bpl.require_range16((a_diff & 0xffff) as u16); bpl.require_range16((a_diff >> 16) as u16); } + } else if is_intro_endo { + // The value relation's coordinate cells + the boundary's + // `EcGroup` pin (authenticating beta/lambda against the + // real group) — the UintMul and EcOnCurveCert demand were + // already routed by `require::intro_endo`. + set(COL_A_PTR, e.a_ptr); + set(COL_B_PTR, e.b_ptr); + set(COL_BOUND_PTR, e.bound_ptr); + set(COL_BETA_PTR, e.beta_ptr); + set(COL_LAMBDA_PTR, e.lambda_ptr); + set(COL_ENDO_BASE_X, e.endo_base_x); + set(COL_ENDO_Y, e.endo_y); + set(COL_ENDO_VAL_X, e.endo_val_x); + set(COL_ENDO_MINTED, e.endo_minted); } else { // intro: the literal-1 scalar's two UintVal halves. store.require_uintval(UintPtr::from_addr(rv.scalar)); @@ -584,6 +707,8 @@ mod tests { UintPtr::from_addr(2), UintPtr::from_addr(3), UintPtr::from_addr(1), + UintPtr::from_addr(0), + UintPtr::from_addr(0), ga, qb, g, @@ -649,7 +774,21 @@ mod tests { out_scalar: one, }, ]; - let c = req.combine(group, sbound, a_ptr, b_ptr, bound_ptr, ga, qb, g, q, gq, combine_rows); + let c = req.combine( + group, + sbound, + a_ptr, + b_ptr, + bound_ptr, + UintPtr::from_addr(0), + UintPtr::from_addr(0), + ga, + qb, + g, + q, + gq, + combine_rows, + ); let neg_rows = vec![ NegRow { i: 0, @@ -665,7 +804,20 @@ mod tests { }, ]; let n = req.neg( - group, sbound, a_ptr, b_ptr, bound_ptr, c, gq, ngq, neg_x, neg_ya, neg_yr, true, + group, + sbound, + a_ptr, + b_ptr, + bound_ptr, + UintPtr::from_addr(0), + UintPtr::from_addr(0), + c, + gq, + ngq, + neg_x, + neg_ya, + neg_yr, + true, neg_rows, ); req.consume_op(ga, 1); @@ -730,4 +882,76 @@ mod tests { crate::tests::check_local(EcMsmAir, &main); } + + #[test] + fn intro_endo_constraints_hold() { + // ⟨P × λ⟩ with val = φ(P) — GLV's endomorphism leaf. Chiplet-local: + // the coordinate/UintMul/cert bus edges balance against nothing + // outside this trace (mult 0), only the native one-hot / boundary / + // scalar-pin constraints are exercised here. + let group = EcGroupPtr::from_addr(1); + let sbound = UintPtr::from_addr(7); + let (a_ptr, b_ptr, bound_ptr) = + (UintPtr::from_addr(2), UintPtr::from_addr(3), UintPtr::from_addr(1)); + let (beta_ptr, lambda_ptr) = (UintPtr::from_addr(10), UintPtr::from_addr(11)); + let base = EcPointPtr::from_addr(3); + let val = EcPointPtr::from_addr(4); + let (endo_base_x, endo_y, endo_val_x) = + (UintPtr::from_addr(20), UintPtr::from_addr(21), UintPtr::from_addr(22)); + let mut req = EcMsmRequires::new(); + let e = req.intro_endo( + group, + sbound, + a_ptr, + b_ptr, + bound_ptr, + beta_ptr, + lambda_ptr, + base, + val, + endo_base_x, + endo_y, + endo_val_x, + true, + ); + req.consume_op(e, 1); + local_check(req); + } + + #[test] + #[should_panic] + fn forged_lambda_ptr_on_intro_endo_rejected() { + // The scalar pin `is_intro_endo · (scalar − lambda_ptr) = 0` is what + // stops λ from being anything but the group's own authenticated + // value (see `require::intro_endo`'s doc comment: λ must never be + // an AIR-known constant). A stray `scalar` that doesn't match + // `lambda_ptr` breaks it, so check_constraints rejects it locally. + let mut store = UintStoreRequires::new(); + let mut bpl = BytePairLutRequires::new(); + let mut req = EcMsmRequires::new(); + let e = req.intro_endo( + EcGroupPtr::from_addr(1), + UintPtr::from_addr(7), + UintPtr::from_addr(2), + UintPtr::from_addr(3), + UintPtr::from_addr(1), + UintPtr::from_addr(10), + UintPtr::from_addr(11), + EcPointPtr::from_addr(3), + EcPointPtr::from_addr(4), + UintPtr::from_addr(20), + UintPtr::from_addr(21), + UintPtr::from_addr(22), + true, + ); + req.consume_op(e, 1); + let mut main = generate_trace(req, &mut store, &mut bpl); + + let row = (0..main.height()) + .find(|&r| main.values[r * NUM_MAIN_COLS + COL_ACT] == Felt::ONE) + .expect("an active row exists"); + main.values[row * NUM_MAIN_COLS + COL_SCALAR] = Felt::from(999u32); + + crate::tests::check_local(EcMsmAir, &main); + } } diff --git a/precompiles-prover/src/ec/require.rs b/precompiles-prover/src/ec/require.rs index 840821cf9f..75cdcb7c03 100644 --- a/precompiles-prover/src/ec/require.rs +++ b/precompiles-prover/src/ec/require.rs @@ -194,6 +194,7 @@ impl<'a> EcRequire<'a> { } let (a, b, bound) = self.store.group_params(group); + let (beta, lambda) = self.store.group_glv_params(group); let (p_group, p_coords) = self.store.point_params(p); let (q_group, q_coords) = self.store.point_params(q); assert!(p_group == group && q_group == group, "add operands must belong to the group"); @@ -273,6 +274,8 @@ impl<'a> EcRequire<'a> { bound, a, b, + beta, + lambda, p, q, r, diff --git a/precompiles-prover/src/ec/trace.rs b/precompiles-prover/src/ec/trace.rs index 52b88d0e50..65ef1409e9 100644 --- a/precompiles-prover/src/ec/trace.rs +++ b/precompiles-prover/src/ec/trace.rs @@ -22,11 +22,12 @@ use miden_core::{Felt, field::QuadFelt, utils::RowMajorMatrix}; use miden_precompiles::CurveId; use super::{ - COL_A_PTR, COL_ACT, COL_B_PTR, COL_BOUND_PTR, COL_ECPOINT_MULT, COL_GROUP_PTR, COL_IS_CERT, - COL_IS_PAI, COL_PTR, COL_SBOUND_PTR, COL_U_PTR, COL_W_PTR, COL_X_PTR, COL_Y_PTR, - EcPointStoreAir, NUM_MAIN_COLS, + COL_A_PTR, COL_ACT, COL_B_PTR, COL_BETA_PTR, COL_BOUND_PTR, COL_ECPOINT_MULT, COL_GROUP_PTR, + COL_IS_CERT, COL_IS_PAI, COL_LAMBDA_PTR, COL_PTR, COL_SBOUND_PTR, COL_U_PTR, COL_W_PTR, + COL_X_PTR, COL_Y_PTR, EcPointStoreAir, NUM_MAIN_COLS, groups::{ - COL_A_PTR as G_COL_A_PTR, COL_B_PTR as G_COL_B_PTR, COL_BOUND_PTR as G_COL_BOUND_PTR, + COL_A_PTR as G_COL_A_PTR, COL_B_PTR as G_COL_B_PTR, COL_BETA_PTR as G_COL_BETA_PTR, + COL_BOUND_PTR as G_COL_BOUND_PTR, COL_LAMBDA_PTR as G_COL_LAMBDA_PTR, COL_MULT as G_COL_MULT, COL_PTR as G_COL_PTR, COL_SBOUND_PTR as G_COL_SBOUND_PTR, EcGroupsAir, NUM_MAIN_COLS as G_NUM_MAIN_COLS, }, @@ -72,13 +73,17 @@ impl EcPointPtr { /// A group-table entry: the curve params + base-field bound, and the /// scalar bound once something constrains it (`None` = vacuous, -/// resolving to `bound` at trace-gen). +/// resolving to `bound` at trace-gen). `beta`/`lambda` are the group's +/// GLV endomorphism params (the none-sentinel `UintPtr::from_addr(0)` +/// for groups with no endomorphism). #[derive(Debug, Clone, Copy)] struct Group { a: UintPtr, b: UintPtr, bound: UintPtr, scalar_bound: Option, + beta: UintPtr, + lambda: UintPtr, } /// A finite point's uint bindings: coordinates plus its membership @@ -147,11 +152,19 @@ impl Default for EcStoreRequires { for curve in CurveId::ALL { let ptr = EcGroupPtr(curve.group_ptr()); debug_assert_eq!(ptr.0 as usize, store.groups.len() + 1); + let (beta, lambda) = match curve.endomorphisms().first() { + Some(endo) => { + (UintPtr::from_addr(endo.beta_ptr), UintPtr::from_addr(endo.lambda_ptr)) + }, + None => (UintPtr::from_addr(0), UintPtr::from_addr(0)), + }; let group = Group { a: UintPtr::from_addr(curve.a_ptr()), b: UintPtr::from_addr(curve.b_ptr()), bound: UintPtr::from_addr(curve.base_domain().bound_ptr()), scalar_bound: Some(UintPtr::from_addr(curve.scalar_domain().bound_ptr())), + beta, + lambda, }; store.by_curve.insert((group.a, group.b, group.bound), ptr); store.groups.push(group); @@ -177,7 +190,14 @@ impl EcStoreRequires { return existing; } let ptr = EcGroupPtr(self.groups.len() as u32 + 1); - self.groups.push(Group { a, b, bound, scalar_bound: None }); + self.groups.push(Group { + a, + b, + bound, + scalar_bound: None, + beta: UintPtr::from_addr(0), + lambda: UintPtr::from_addr(0), + }); self.by_curve.insert((a, b, bound), ptr); ptr } @@ -310,6 +330,14 @@ impl EcStoreRequires { (g.a, g.b, g.bound) } + /// The group's GLV endomorphism params `(beta, lambda)` — the + /// none-sentinel `(UintPtr::from_addr(0), UintPtr::from_addr(0))` for + /// a group with no endomorphism. + pub fn group_glv_params(&self, group: EcGroupPtr) -> (UintPtr, UintPtr) { + let g = &self.groups[group.0 as usize - 1]; + (g.beta, g.lambda) + } + /// The group's **resolved** scalar-bound handle: the constrained /// `F_s` modulus if set, else (vacuously) the group's own `bound` — /// the value every `EcGroup` tuple site lays in its trace cell. @@ -365,6 +393,8 @@ fn groups_trace(requires: &EcStoreRequires) -> RowMajorMatrix { row[G_COL_B_PTR] = Felt::from(group.b.addr()); row[G_COL_BOUND_PTR] = Felt::from(group.bound.addr()); row[G_COL_SBOUND_PTR] = Felt::from(group.scalar_bound.unwrap_or(group.bound).addr()); + row[G_COL_BETA_PTR] = Felt::from(group.beta.addr()); + row[G_COL_LAMBDA_PTR] = Felt::from(group.lambda.addr()); row[G_COL_MULT] = Felt::from(requires.group_demand.get(&EcGroupPtr(ptr)).copied().unwrap_or(0)); } @@ -391,6 +421,9 @@ fn points_trace(requires: &EcStoreRequires) -> RowMajorMatrix { row[COL_B_PTR] = Felt::from(b.addr()); row[COL_BOUND_PTR] = Felt::from(bound.addr()); row[COL_SBOUND_PTR] = Felt::from(requires.group_sbound(point.group).addr()); + let (beta, lambda) = requires.group_glv_params(point.group); + row[COL_BETA_PTR] = Felt::from(beta.addr()); + row[COL_LAMBDA_PTR] = Felt::from(lambda.addr()); row[COL_X_PTR] = Felt::from(point.binding.map_or(0, |b| b.x.addr())); row[COL_Y_PTR] = Felt::from(point.binding.map_or(0, |b| b.y.addr())); let membership = point.binding.and_then(|b| b.membership); diff --git a/precompiles-prover/src/session/fixed.rs b/precompiles-prover/src/session/fixed.rs index cb065d78dc..59be43ed99 100644 --- a/precompiles-prover/src/session/fixed.rs +++ b/precompiles-prover/src/session/fixed.rs @@ -26,6 +26,16 @@ pub(crate) fn fixed_uints() -> impl Iterator { (curve.b_ptr(), bound_ptr, curve.b_value()), ] })) + .chain(CurveId::ALL.into_iter().flat_map(|curve| { + let base_bound_ptr = curve.base_domain().bound_ptr(); + let scalar_bound_ptr = curve.scalar_domain().bound_ptr(); + curve.endomorphisms().iter().flat_map(move |endo| { + [ + (endo.beta_ptr, base_bound_ptr, endo.beta), + (endo.lambda_ptr, scalar_bound_ptr, endo.lambda), + ] + }) + })) } /// Verifier-side `UintVal` consumes for the fixed uint environment. @@ -39,11 +49,19 @@ pub(crate) fn fixed_uintval_msgs() -> impl Iterator> { /// Verifier-side `EcGroup` consumes for the fixed curve groups. pub(crate) fn fixed_ecgroup_msgs() -> impl Iterator> { - CurveId::ALL.into_iter().map(|curve| EcGroupMsg { - group_ptr: Felt::from(curve.group_ptr()), - a_ptr: Felt::from(curve.a_ptr()), - b_ptr: Felt::from(curve.b_ptr()), - bound_ptr: Felt::from(curve.base_domain().bound_ptr()), - scalar_bound_ptr: Felt::from(curve.scalar_domain().bound_ptr()), + CurveId::ALL.into_iter().map(|curve| { + let (beta_ptr, lambda_ptr) = match curve.endomorphisms().first() { + Some(endo) => (endo.beta_ptr, endo.lambda_ptr), + None => (0, 0), + }; + EcGroupMsg { + group_ptr: Felt::from(curve.group_ptr()), + a_ptr: Felt::from(curve.a_ptr()), + b_ptr: Felt::from(curve.b_ptr()), + bound_ptr: Felt::from(curve.base_domain().bound_ptr()), + scalar_bound_ptr: Felt::from(curve.scalar_domain().bound_ptr()), + beta_ptr: Felt::from(beta_ptr), + lambda_ptr: Felt::from(lambda_ptr), + } }) } diff --git a/precompiles-prover/src/session/mod.rs b/precompiles-prover/src/session/mod.rs index 4e396c4e48..e4f42ffdda 100644 --- a/precompiles-prover/src/session/mod.rs +++ b/precompiles-prover/src/session/mod.rs @@ -309,6 +309,14 @@ impl Session { require::intro(&mut self.msm, &mut self.ec, &mut self.uint, point.point) } + /// Promote a stored point `P` to the 1-term MSM expression `⟨P × λ⟩` + /// (value `= φ(P)`) — GLV's endomorphism leaf. Chiplet-internal, like + /// [`msm_intro`](Self::msm_intro). Mechanism in + /// [`msm::require::intro_endo`](crate::ec::msm::require::intro_endo). + pub fn msm_intro_endo(&mut self, point: &EcNode) -> EcExprPtr { + require::intro_endo(&mut self.msm, &mut self.ec, &mut self.uint, point.point) + } + /// Combine two MSM expressions: union their term multisets (shared-base /// scalars merge `mod` the scalar bound) and add their values; the /// operands' use counts are bumped. Mechanism in diff --git a/precompiles-prover/src/session/strategies.rs b/precompiles-prover/src/session/strategies.rs index dca3045221..dc95f5f351 100644 --- a/precompiles-prover/src/session/strategies.rs +++ b/precompiles-prover/src/session/strategies.rs @@ -25,9 +25,11 @@ use alloc::{vec, vec::Vec}; +use miden_precompiles::{CurveId, glv_decompose}; + use crate::{ ec::msm::trace::EcExprPtr, - math::U256, + math::{U256, from_limbs32, to_limbs32}, session::{EcNode, Session}, }; @@ -182,15 +184,34 @@ pub struct WnafTable { /// `2^{w-2}`-entry table. Precompute a recurring base's table **once** and /// share the result across every [`wnaf_scalarmul`] on it. pub fn wnaf_table(session: &mut Session, base: &EcNode, w: usize) -> WnafTable { - assert!((2..=8).contains(&w), "wNAF window w ∈ [2, 8]"); let p1 = session.msm_intro(base); // ⟨P×1⟩ - let two_p = session.msm_combine(p1, p1); // ⟨P×2⟩ — the odd-multiple step + wnaf_table_from_seed(session, p1, w) +} + +/// [`wnaf_table`]'s GLV endomorphism-leg twin: precomputes `base`'s **positive-only** endomorphism +/// table (odd multiples of `φ(P)`, expressed as terms on `P` with scalar `j·λ` via +/// [`Session::msm_intro_endo`]) instead of the plain `⟨P×1⟩` table. Always seeded `⟨P×λ⟩`, never +/// `⟨P×−λ⟩` — sign rides the digit selection in [`joint_wnaf_with_signed_tables`], not the seed — +/// so the same table serves every GLV split's `b` half regardless of its sign, letting a recurring +/// base's endomorphism table be built once and reused, like [`wnaf_table`]'s plain leg. +pub fn wnaf_table_endo(session: &mut Session, base: &EcNode, w: usize) -> WnafTable { + let e1 = session.msm_intro_endo(base); // ⟨P×λ⟩ + wnaf_table_from_seed(session, e1, w) +} + +/// [`wnaf_table`]'s stage 1, taking an already-built 1-term seed expression +/// instead of always `intro`ing `base` fresh — lets a caller drive the +/// table off a different unit term, e.g. GLV's `⟨P×λ⟩` endomorphism leaf +/// ([`Session::msm_intro_endo`]) or a negated seed (a signed GLV half). +fn wnaf_table_from_seed(session: &mut Session, seed: EcExprPtr, w: usize) -> WnafTable { + assert!((2..=8).contains(&w), "wNAF window w ∈ [2, 8]"); + let two_p = session.msm_combine(seed, seed); // ⟨seed×2⟩ — the odd-multiple step let n_odds = 1usize << (w - 2); let mut odds = Vec::with_capacity(n_odds); - odds.push(p1); - let mut cur = p1; + odds.push(seed); + let mut cur = seed; for _ in 1..n_odds { - cur = session.msm_combine(cur, two_p); // shared base ⇒ +2P → next odd + cur = session.msm_combine(cur, two_p); // shared base ⇒ +2·seed → next odd odds.push(cur); } WnafTable { odds, w } @@ -260,11 +281,44 @@ pub fn wnaf_msm(session: &mut Session, terms: &[(&WnafTable, U256)]) -> EcExprPt /// /// `terms` pairs each base with its **non-negative** scalar (GLV signs ride /// the base via transcript `ec_sub(∞, P)` upstream, so the magnitudes land -/// here); each base's [`WnafTable`] is built per call. Returns the combined -/// MSM expression. Panics if `terms` is empty or every scalar is zero. +/// here); each base's [`WnafTable`] is built fresh. Returns the combined MSM +/// expression. Panics if `terms` is empty or every scalar is zero. +/// +/// When a base recurs across many calls — the generator across a batch of +/// signatures — build its table once with [`wnaf_table`] and drive the same +/// interleaved ladder with [`joint_wnaf_with_tables`] instead, so the +/// recurring base's table is laid once rather than rebuilt per call. pub fn joint_wnaf(session: &mut Session, terms: &[(EcNode, U256)], w: usize) -> EcExprPtr { let tables: Vec = terms.iter().map(|(p, _)| wnaf_table(session, p, w)).collect(); - let digits: Vec> = terms.iter().map(|(_, k)| wnaf(*k, w)).collect(); + let table_terms: Vec<(&WnafTable, U256)> = + tables.iter().zip(terms).map(|(table, &(_, k))| (table, k)).collect(); + joint_wnaf_with_tables(session, &table_terms) +} + +/// The interleaved wNAF ladder behind [`joint_wnaf`], taking already-built +/// [`WnafTable`]s instead of building one per base — lets a caller reuse a +/// recurring base's table (built once via [`wnaf_table`]) across many MSM +/// claims instead of rebuilding it per claim. Each table drives its own digit +/// expansion at its own window (`table.w`). Returns the combined MSM +/// expression. Panics if `terms` is empty or every scalar is zero. +pub fn joint_wnaf_with_tables(session: &mut Session, terms: &[(&WnafTable, U256)]) -> EcExprPtr { + let signed_terms: Vec<(&WnafTable, U256, bool)> = + terms.iter().map(|&(table, k)| (table, k, false)).collect(); + joint_wnaf_with_signed_tables(session, &signed_terms) +} + +/// [`joint_wnaf_with_tables`]'s general form: each term also carries an overall sign, applied on +/// top of the digit's own sign (`effective_negative = (digit < 0) XOR negate`) instead of baked +/// into the table's seed. This is what lets a **positive-only** table — always seeded `⟨P×1⟩`, +/// never `⟨P×−1⟩` — serve a term whose caller-side scalar is conceptually negative (GLV's signed +/// halves), so the table stays cacheable across every sign a recurring base's scalar might take. +/// `joint_wnaf_with_tables` is the `negate = false` special case. Returns the combined MSM +/// expression. Panics if `terms` is empty or every scalar is zero. +pub fn joint_wnaf_with_signed_tables( + session: &mut Session, + terms: &[(&WnafTable, U256, bool)], +) -> EcExprPtr { + let digits: Vec> = terms.iter().map(|(table, k, _)| wnaf(*k, table.w)).collect(); let len = digits.iter().map(Vec::len).max().unwrap_or(0); let mut acc: Option = None; @@ -275,11 +329,12 @@ pub fn joint_wnaf(session: &mut Session, terms: &[(EcNode, U256)], w: usize) -> acc = Some(session.msm_combine(a, a)); } // Then each base adds its digit's (signed) table entry at this column. - for (table, base_digits) in tables.iter().zip(&digits) { + for ((table, _, negate), base_digits) in terms.iter().zip(&digits) { let d = base_digits.get(i).copied().unwrap_or(0); if d != 0 { let pos = table.odds[(d.unsigned_abs() as usize - 1) / 2]; - let entry = if d > 0 { pos } else { session.msm_neg(pos) }; + let want_neg = (d < 0) ^ *negate; + let entry = if want_neg { session.msm_neg(pos) } else { pos }; acc = Some(match acc { None => entry, // lazy-seed at the first nonzero digit Some(a) => session.msm_combine(a, entry), @@ -290,6 +345,86 @@ pub fn joint_wnaf(session: &mut Session, terms: &[(EcNode, U256)], w: usize) -> acc.expect("joint_wnaf needs a nonzero scalar") } +/// Build `Σ kᵢ·Pᵢ` for a curve with a GLV endomorphism by decomposing each +/// scalar ([`glv_decompose`]) into a signed pair `k ≡ ka + λ·kb (mod n)`, +/// each half roughly half `k`'s bit-width, then driving **one** shared +/// interleaved wNAF ladder ([`joint_wnaf_with_signed_tables`]) over the +/// `2·terms.len()` resulting virtual bases: `Pᵢ`'s own table (`⟨Pᵢ×1⟩`) and +/// its endomorphism table (`⟨Pᵢ×λ⟩` via [`Session::msm_intro_endo`], value +/// `φ(Pᵢ)`) — each half's sign rides the digit selection, not the seed, so +/// both tables stay positive-only and cacheable (see +/// [`glv_joint_wnaf_with_tables`]). +/// +/// Because `intro_endo`'s term rides `Pᵢ` — not `φ(Pᵢ)` — `msm_combine`'s +/// shared-base merge folds each pair's plain/endo legs back onto **one** +/// term per real base, `⟨Pᵢ × (ka + λ·kb mod n)⟩`, for free: the caller's +/// claim still names only the original `m` bases, at roughly half the +/// ladder height of a plain `2·terms.len()`-base joint wNAF over the full +/// scalar width. `φ(Pᵢ)`'s on-curve membership is proved in-circuit by +/// `intro_endo`'s value relation (not trusted host advice), so a +/// decomposed half landing on zero, or two virtual bases coinciding in +/// value, are both harmless — a zero-magnitude table's digits are all +/// zero (contributing nothing, same as any other zero column), and a +/// coordinate collision is ordinary point-store dedup, not a forgery +/// surface (see `glv.rs`'s β-orbit note, which is a MASM-side concern +/// this in-circuit certification closes, not a prover-strategy one). +/// +/// Builds each base's tables fresh. When a base recurs across many calls — +/// the generator across a batch of signatures — build its tables once with +/// [`wnaf_table`] / [`wnaf_table_endo`] and drive the same ladder with +/// [`glv_joint_wnaf_with_tables`] instead. +/// +/// Returns the combined MSM expression. Panics if `terms` is empty, any +/// scalar is zero, or `curve` has no GLV endomorphism. +pub fn glv_joint_wnaf( + session: &mut Session, + curve: CurveId, + terms: &[(EcNode, U256)], + w: usize, +) -> EcExprPtr { + assert!(!curve.endomorphisms().is_empty(), "glv_joint_wnaf needs a GLV endomorphism"); + assert!(!terms.is_empty(), "an MSM needs at least one base"); + + let tables: Vec<(WnafTable, WnafTable)> = terms + .iter() + .map(|(p, _)| (wnaf_table(session, p, w), wnaf_table_endo(session, p, w))) + .collect(); + let table_terms: Vec<(&WnafTable, &WnafTable, U256)> = tables + .iter() + .zip(terms) + .map(|((plain, endo), &(_, k))| (plain, endo, k)) + .collect(); + glv_joint_wnaf_with_tables(session, &table_terms) +} + +/// The interleaved GLV ladder behind [`glv_joint_wnaf`], taking already-built +/// **positive-only** `(plain_table, endo_table)` pairs instead of building +/// them per base — lets a caller reuse a recurring base's tables (built once +/// via [`wnaf_table`] / [`wnaf_table_endo`]) across many MSM claims instead +/// of rebuilding them per claim, the same way [`joint_wnaf_with_tables`] +/// does for the non-GLV path. Each call still re-decomposes `k` ([`glv_decompose`]) +/// since the split (and its signs) is scalar-specific; only the tables — +/// which depend on the base alone — are shared. +/// +/// `terms` pairs each base's `(plain_table, endo_table, scalar)`. Returns +/// the combined MSM expression. Panics if `terms` is empty or any scalar is +/// zero. +pub fn glv_joint_wnaf_with_tables( + session: &mut Session, + terms: &[(&WnafTable, &WnafTable, U256)], +) -> EcExprPtr { + assert!(!terms.is_empty(), "an MSM needs at least one base"); + + let mut signed_terms: Vec<(&WnafTable, U256, bool)> = Vec::with_capacity(terms.len() * 2); + for &(plain, endo, k) in terms { + assert_ne!(k, U256::ZERO, "glv_joint_wnaf_with_tables terms must have a nonzero scalar"); + let [(a_neg, a_mag), (b_neg, b_mag)] = glv_decompose(to_limbs32(k)); + signed_terms.push((plain, from_limbs32(&a_mag), a_neg)); + signed_terms.push((endo, from_limbs32(&b_mag), b_neg)); + } + joint_wnaf_with_signed_tables(session, &signed_terms) +} + /// Non-adjacent form of `k` (digits LSB-first, each in `{−1, 0, 1}`, no two /// adjacent nonzero) — ~⅓ density vs binary's ½. `d = 2 − (k mod 4)` on the /// odd steps (`k mod 4 ∈ {1, 3} → d ∈ {1, −1}`), then `k ← (k − d)/2`. diff --git a/precompiles-prover/src/snapshots/miden_precompiles_prover__stark_config__tests__precompile_relation_digest_matches_current_air.snap b/precompiles-prover/src/snapshots/miden_precompiles_prover__stark_config__tests__precompile_relation_digest_matches_current_air.snap index 5fe0f8d7be..7d35970aa5 100644 --- a/precompiles-prover/src/snapshots/miden_precompiles_prover__stark_config__tests__precompile_relation_digest_matches_current_air.snap +++ b/precompiles-prover/src/snapshots/miden_precompiles_prover__stark_config__tests__precompile_relation_digest_matches_current_air.snap @@ -2,7 +2,7 @@ source: precompiles-prover/src/stark_config.rs expression: snapshot --- -num_inputs: 3128 -num_eval_gates: 9664 -stream_len: 12680 -relation_digest: [15901056294547705196, 13548154566962352054, 13148050606838836712, 2433548564999773594] +num_inputs: 3160 +num_eval_gates: 9768 +stream_len: 12784 +relation_digest: [4556049517489570937, 9759296621390318849, 3847887631299869183, 11939138443228661433] diff --git a/precompiles-prover/src/stark_config.rs b/precompiles-prover/src/stark_config.rs index 1f06160bf5..b852198055 100644 --- a/precompiles-prover/src/stark_config.rs +++ b/precompiles-prover/src/stark_config.rs @@ -48,10 +48,10 @@ const COMPRESSION_INPUTS: usize = 2; /// the lifted STARK protocol outside this circuit hash. /// Keep this in sync with [`crate::ace::build_precompile_multi_air_ace_circuit`]. pub const PRECOMPILE_RELATION_DIGEST: RelationDigest = [ - Felt::new_unchecked(15901056294547705196), - Felt::new_unchecked(13548154566962352054), - Felt::new_unchecked(13148050606838836712), - Felt::new_unchecked(2433548564999773594), + Felt::new_unchecked(4556049517489570937), + Felt::new_unchecked(9759296621390318849), + Felt::new_unchecked(3847887631299869183), + Felt::new_unchecked(11939138443228661433), ]; /// Default hash function for compatibility APIs such as /// [`SessionTraces::prove`](crate::session::SessionTraces::prove). diff --git a/precompiles-prover/src/tests/ec_msm.rs b/precompiles-prover/src/tests/ec_msm.rs index c56507bd3e..38757f730d 100644 --- a/precompiles-prover/src/tests/ec_msm.rs +++ b/precompiles-prover/src/tests/ec_msm.rs @@ -14,14 +14,17 @@ use std::{format, string::String}; use k256::{ProjectivePoint, elliptic_curve::sec1::ToSec1Point}; use miden_core::{Felt, utils::Matrix}; -use miden_precompiles::CurveId; +use miden_precompiles::{CurveId, CurvePoint}; use crate::{ ec::msm::EcMsmAir, - math::{U256, from_hex}, + math::{U256, from_hex, from_limbs32, to_limbs32}, session::{ EcNode, Session, - strategies::{joint_naf, joint_wnaf, straus, wnaf_msm, wnaf_table}, + strategies::{ + glv_joint_wnaf, glv_joint_wnaf_with_tables, joint_naf, joint_wnaf, straus, wnaf_msm, + wnaf_table, wnaf_table_endo, + }, verify_deferred, }, tests::check_local_inputs, @@ -178,6 +181,126 @@ fn msm_intro_neg_proves() { .expect("EcMsm intro+neg round-trip must verify"); } +/// `⟨G × λ⟩` — GLV's endomorphism leaf, value `φ(G)`. Unused (mult 0): it +/// consumes `G` and routes the coordinate/`UintMul`/on-curve-cert demand, +/// closing the bus. Cross-checks the in-circuit value against +/// [`CurveId::endomorphisms`]'s independently-defined `φ(G)` (itself tested +/// against the β-orbit in `glv.rs`), so this is a correctness check of +/// `require::intro_endo`'s native math, not just its local constraints. +fn msm_intro_endo_traces() -> crate::session::SessionTraces { + let g = ProjectivePoint::GENERATOR; + let (gx, gy) = k256_coords(&g); + + let mut s = Session::new(); + + let g_pt = create(&mut s, gx, gy); + let _e = s.msm_intro_endo(&g_pt); + + let claim_g = s.ec_is(&g_pt, &g_pt); + let root = s.assert_and_fold([claim_g]); + s.finish(root) +} + +#[test] +fn msm_intro_endo_checks() { + let traces = msm_intro_endo_traces(); + traces.check(); +} + +#[test] +#[ignore = "full prove/verify round-trip; run explicitly"] +fn msm_intro_endo_proves() { + verify_deferred(&msm_intro_endo_traces().prove()) + .expect("EcMsm intro_endo round-trip must verify"); +} + +#[test] +fn msm_intro_endo_value_matches_endomorphism_image() { + let g = ProjectivePoint::GENERATOR; + let (gx, gy) = k256_coords(&g); + + let mut s = Session::new(); + let g_pt = create(&mut s, gx, gy); + let expr = s.msm_intro_endo(&g_pt); + let (x, y) = s.msm_value_coords(expr); + + let (_, phi_g) = CurveId::Secp256k1 + .extra_points() + .into_iter() + .find(|&(name, _)| name == "PHI_GENERATOR") + .expect("secp256k1 has a PHI_GENERATOR extra point"); + let CurvePoint::Affine { x: expected_x, y: expected_y } = phi_g else { + panic!("PHI_GENERATOR must be an affine point"); + }; + assert_eq!(x, from_limbs32(&expected_x), "intro_endo's φ(G).x must match PHI_GENERATOR"); + assert_eq!(y, from_limbs32(&expected_y), "intro_endo's φ(G).y must match PHI_GENERATOR"); +} + +/// `glv_joint_wnaf`'s value for a genuinely large (not 1, not small) scalar +/// `u·G`, cross-checked against `CurveId::mul_scalar`'s independent native +/// reference — the real correctness check that the GLV split +/// (`glv_decompose`), the two per-half wNAF ladders (plain + endomorphism), +/// and `msm_combine`'s shared-base merge back onto `⟨G × u⟩` all land on +/// the same value a plain double-and-add would. +#[test] +fn glv_joint_wnaf_value_matches_native_mul_scalar() { + let curve = CurveId::Secp256k1; + let g = ProjectivePoint::GENERATOR; + let (gx, gy) = k256_coords(&g); + // An arbitrary large scalar, safely canonical under n (n's top byte is + // 0xff, so any 256-bit value with a smaller top byte is < n). + let u = from_hex("89abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345"); + + let mut s = Session::new(); + let g_pt = create(&mut s, gx, gy); + let expr = glv_joint_wnaf(&mut s, curve, &[(g_pt, u)], 5); + let (x, y) = s.msm_value_coords(expr); + + let expected = curve + .mul_scalar(curve.generator(), to_limbs32(u)) + .expect("valid scalar multiplication"); + let CurvePoint::Affine { x: expected_x, y: expected_y } = expected else { + panic!("u·G must be finite for this u"); + }; + assert_eq!(x, from_limbs32(&expected_x), "GLV value.x must match the native reference"); + assert_eq!(y, from_limbs32(&expected_y), "GLV value.y must match the native reference"); +} + +/// `glv_joint_wnaf_with_tables`'s cached-table path — the shape `translate_ec_msm` uses across a +/// signature batch: `G`'s plain/endo tables are built once, then reused across several claims +/// with different scalars (and hence different GLV split signs, since each half's sign rides the +/// digit selection, not the table's seed). Each claim's value is checked against the native +/// reference, so this exercises every sign combination `glv_decompose` can hand back against the +/// same positive-only tables. +#[test] +fn glv_joint_wnaf_with_tables_reused_across_scalars() { + let curve = CurveId::Secp256k1; + let g = ProjectivePoint::GENERATOR; + let (gx, gy) = k256_coords(&g); + + let mut s = Session::new(); + let g_pt = create(&mut s, gx, gy); + let plain = wnaf_table(&mut s, &g_pt, 5); + let endo = wnaf_table_endo(&mut s, &g_pt, 5); + + for u in [ + from_hex("1"), + from_hex("89abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345"), + from_hex("7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + from_hex("123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde"), + ] { + let expr = glv_joint_wnaf_with_tables(&mut s, &[(&plain, &endo, u)]); + let (x, y) = s.msm_value_coords(expr); + let expected = + curve.mul_scalar(curve.generator(), to_limbs32(u)).expect("valid scalar mul"); + let CurvePoint::Affine { x: expected_x, y: expected_y } = expected else { + panic!("u·G must be finite for this u"); + }; + assert_eq!(x, from_limbs32(&expected_x), "cached GLV value.x must match for u={u:?}"); + assert_eq!(y, from_limbs32(&expected_y), "cached GLV value.y must match for u={u:?}"); + } +} + /// In-circuit resolve of the 1-term claim `R = 1·G` (`R = G`): `msm_intro` /// then `msm_resolve` lays the eval `EcMsm` node (a single absorb row, the /// IV its cap) binding the value, and the `Is` ties it to `G`. The claim diff --git a/precompiles/Cargo.toml b/precompiles/Cargo.toml index 8206de8e50..0e6197e74b 100644 --- a/precompiles/Cargo.toml +++ b/precompiles/Cargo.toml @@ -21,9 +21,10 @@ doctest = false [features] default = ["std"] -std = ["miden-core/std", "miden-crypto/std"] +std = ["miden-core/std", "miden-crypto/std", "ruint/std"] [dependencies] # Miden dependencies miden-core.workspace = true miden-crypto.workspace = true +ruint.workspace = true diff --git a/precompiles/benches/precompiles_bench/input_generation.rs b/precompiles/benches/precompiles_bench/input_generation.rs index 50a4d0e5bd..d5574524fa 100644 --- a/precompiles/benches/precompiles_bench/input_generation.rs +++ b/precompiles/benches/precompiles_bench/input_generation.rs @@ -43,7 +43,14 @@ pub(crate) fn generate_advice_inputs(workload: PrecompileWorkload) -> AdviceInpu advice_stack.append_for_adv_pipe(&ecdsa_k256_keccak::encode_signature(&pk, &signature)); } - assert_eq!(advice_stack.len(), workload.ecdsas * 40, "unexpected ECDSA advice length"); + // The message and public-key commitment words, plus `encode_signature`'s output (32 felts of + // PK/SIG, already a multiple of 8 -- no padding needed). + let felts_per_ecdsa = 8 + 32; + assert_eq!( + advice_stack.len(), + workload.ecdsas * felts_per_ecdsa, + "unexpected ECDSA advice length", + ); AdviceInputs::default().with_advice_stack(advice_stack) } diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs index 2b5d7a861e..6e5d1702f3 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -16,8 +16,9 @@ pub use hash::{HashAssertNode, HashFunction, HashPrecompile, keccak256::Keccak25 pub use math::{ curve::{ CurveCoefficient, CurveId, CurveNodeRef, CurvePoint, CurvePrecompile, CurveSpec, K1_A_PTR, - K1_B_PTR, K1_GROUP_PTR, SECP256K1_GENERATOR_X, SECP256K1_GENERATOR_Y, SECP256K1_ID, - ShortWeierstrassSpec, curve_coefficients, + K1_B_PTR, K1_GROUP_PTR, SECP256K1_BETA, SECP256K1_GENERATOR_X, SECP256K1_GENERATOR_Y, + SECP256K1_ID, SECP256K1_LAMBDA, ShortWeierstrassSpec, curve_coefficients, glv_decompose, + phi_generator, scalar_mul_mod_n, }, k1_base::K1Base, k1_scalar::K1Scalar, diff --git a/precompiles/src/math/curve/glv.rs b/precompiles/src/math/curve/glv.rs new file mode 100644 index 0000000000..5b192b6d57 --- /dev/null +++ b/precompiles/src/math/curve/glv.rs @@ -0,0 +1,309 @@ +//! secp256k1 GLV endomorphism: scalar decomposition and the constants it needs. +//! +//! The endomorphism `φ(x, y) = (β·x mod p, y)` acts as multiplication by `λ` on the group +//! (`φ(P) = λ·P`), so any scalar multiplication `k·P` can be rewritten `k₁·P + k₂·φ(P)` with +//! `k₁, k₂` roughly half the bit-width of `k`. [`glv_decompose`] performs the split natively +//! (host side, untrusted advice); the in-circuit certificate binding `φ(P)` to `P` and the split +//! back to the original scalar is the caller's responsibility. + +use ruint::Uint; + +use super::{CurvePoint, SECP256K1_GENERATOR_X, SECP256K1_GENERATOR_Y}; +use crate::math::{ + k1_scalar::K1Scalar, + uint::{Limbs, UintDomain}, +}; + +/// secp256k1 GLV endomorphism scalar `λ` (`λ³ ≡ 1 mod n`, `n` the curve order): `φ(P) = λ·P`. +pub const SECP256K1_LAMBDA: Limbs = [ + 0x1b23bd72, 0xdf02967c, 0x20816678, 0x122e22ea, 0x8812645a, 0xa5261c02, 0xc05c30e0, 0x5363ad4c, +]; + +/// secp256k1 GLV base-field constant `β` (`β³ ≡ 1 mod p`, `p` the base field modulus): +/// `φ(x, y) = (β·x mod p, y)`. +pub const SECP256K1_BETA: Limbs = [ + 0x719501ee, 0xc1396c28, 0x12f58995, 0x9cf04975, 0xac3434e9, 0x6e64479e, 0x657c0710, 0x7ae96a2b, +]; + +/// The secp256k1 GLV endomorphism image of the generator, `φ(G) = (β·G_x mod p, G_y)`. `G` is +/// fixed, so this is a compile-time-derivable point: `crates/lib/core/codegen` calls this to bake +/// its digest into the generated `secp256k1.masm` as `PHI_GENERATOR_DIGEST`/`push_phi_generator`, +/// the same way the generator and identity points are baked in, rather than every caller +/// recomputing `β·G_x` with a live in-circuit multiplication. +pub fn phi_generator() -> CurvePoint { + CurvePoint::Affine { + x: UintDomain::K1Base.mul(SECP256K1_BETA, SECP256K1_GENERATOR_X), + y: SECP256K1_GENERATOR_Y, + } +} + +/// The base-field x-coordinates `G_x`, `β·G_x`, `β²·G_x` — the orbit of the generator's +/// x-coordinate under the endomorphism. +/// +/// A public key `Q` whose x-coordinate lies in this orbit makes a 4-base GLV split +/// `{±G, ±φ(G), ±Q, ±φ(Q)}` carry the same point twice, which an MSM over distinct bases cannot +/// express. The three cases are `Q = ±G` (`Q_x = G_x`), `Q = ±φ(G)` (`Q_x = β·G_x`), and +/// `φ(Q) = ±G` (`β·Q_x = G_x`, so `Q_x = β²·G_x` because `β³ = 1`). The remaining pairing, +/// `Q = ±φ(Q)`, would need `Q_x = 0`, and no secp256k1 point has one: it would require `y² = 7`, +/// and `7` is a quadratic non-residue mod `p`. +pub fn generator_x_phi_orbit() -> [Limbs; 3] { + let beta_gx = UintDomain::K1Base.mul(SECP256K1_BETA, SECP256K1_GENERATOR_X); + let beta2_gx = UintDomain::K1Base.mul(SECP256K1_BETA, beta_gx); + [SECP256K1_GENERATOR_X, beta_gx, beta2_gx] +} + +/// A magnitude type wide enough to hold every intermediate value [`glv_decompose`]'s Babai +/// rounding produces. The largest is a short-basis-coefficient-by-scalar product (a ~128-bit +/// basis magnitude times a ~256-bit scalar), which peaks at 384 bits — this leaves 128 bits of +/// headroom. +type Wide = Uint<512, 8>; + +fn wide_zero() -> Wide { + Wide::from_limbs([0; 8]) +} + +fn wide_one() -> Wide { + let mut limbs = [0u64; 8]; + limbs[0] = 1; + Wide::from_limbs(limbs) +} + +fn limbs_to_wide(limbs: Limbs) -> Wide { + let mut u64_limbs = [0u64; 8]; + for i in 0..4 { + u64_limbs[i] = (limbs[2 * i] as u64) | ((limbs[2 * i + 1] as u64) << 32); + } + Wide::from_limbs(u64_limbs) +} + +/// Converts a reduced `Wide` value back to `Limbs`. Panics if the value doesn't actually fit in +/// 256 bits — every value this module ever converts back is a GLV magnitude bounded well under +/// the curve order, so a nonzero high limb indicates a bug in the reduction above, not a valid +/// (if merely suboptimal) result. +fn wide_to_limbs(v: Wide) -> Limbs { + let u64_limbs = v.as_limbs(); + assert!(u64_limbs[4..].iter().all(|&l| l == 0), "GLV magnitude must fit in 256 bits"); + core::array::from_fn(|i| { + let word = u64_limbs[i / 2]; + if i % 2 == 0 { word as u32 } else { (word >> 32) as u32 } + }) +} + +/// A sign-magnitude integer over [`Wide`] — the GLV lattice arithmetic below needs signed +/// intermediate values (the extended-Euclid Bézout coefficients), while the moduli and remainders +/// stay unsigned. +#[derive(Clone, Copy)] +struct Signed { + neg: bool, + mag: Wide, +} + +impl Signed { + fn new(neg: bool, mag: Wide) -> Self { + // Canonicalize the sign of zero so equality/negation stay simple. + if mag == wide_zero() { + Signed { neg: false, mag } + } else { + Signed { neg, mag } + } + } + + fn negate(self) -> Self { + Signed::new(!self.neg, self.mag) + } + + fn add(self, other: Self) -> Self { + if self.neg == other.neg { + Signed::new(self.neg, self.mag + other.mag) + } else if self.mag >= other.mag { + Signed::new(self.neg, self.mag - other.mag) + } else { + Signed::new(other.neg, other.mag - self.mag) + } + } + + fn sub(self, other: Self) -> Self { + self.add(other.negate()) + } + + fn mul(self, other: Self) -> Self { + Signed::new(self.neg != other.neg, self.mag * other.mag) + } + + /// `round(self / n)` as a signed integer. Ties round up in magnitude; the exact tie-breaking + /// rule is a performance choice, not a soundness one — the recompose relation this feeds + /// holds for *any* integer quotient (see [`glv_decompose`]'s doc comment). + fn div_round(self, n: Wide) -> Self { + let q = self.mag / n; + let r = self.mag % n; + let q = if r + r >= n { q + wide_one() } else { q }; + Signed::new(self.neg, q) + } +} + +/// The short lattice basis `(a1, b1), (a2, b2)` [`glv_decompose`] rounds against — the result of +/// applying a half extended-Euclid shortest-lattice-vector reduction to `(n, λ)` followed by one +/// step of comparing candidate short vectors by norm (Hankerson–Menezes–Vanstone, Algorithm +/// 3.74), computed once here since `n` and `λ` are fixed. +const GLV_BASIS: [(bool, Limbs); 4] = [ + // a1 + (false, [0x9284eb15, 0xe86c90e4, 0xa7d46bcd, 0x3086d221, 0, 0, 0, 0]), + // b1 + (true, [0x0abfe4c3, 0x6f547fa9, 0x010e8828, 0xe4437ed6, 0, 0, 0, 0]), + // a2 + (false, [0x9d44cfd8, 0x57c1108d, 0xa8e2f3f6, 0x14ca50f7, 0x00000001, 0, 0, 0]), + // b2 + (false, [0x9284eb15, 0xe86c90e4, 0xa7d46bcd, 0x3086d221, 0, 0, 0, 0]), +]; + +/// Splits `k` (implicitly reduced mod `n`, the secp256k1 scalar-field order) into a signed short +/// pair `[(neg_a, mag_a), (neg_b, mag_b)]` with `k ≡ (±mag_a) + λ·(±mag_b) (mod n)`, each +/// magnitude bounded well under `n` — typically close to half its bit-width — by one Babai +/// rounding step against the precomputed short lattice basis (Hankerson–Menezes–Vanstone, +/// Algorithm 3.74). +/// +/// The in-circuit certificate this decomposition feeds re-derives the same congruence from the +/// returned halves and accepts it unconditionally: a less-than-optimal rounding here only costs +/// the addition chain some extra bit-width, it can never make the certificate unsound. +pub fn glv_decompose(k: Limbs) -> [(bool, Limbs); 2] { + let n = limbs_to_wide(K1Scalar::MODULUS); + let [(a1_neg, a1_mag), (b1_neg, b1_mag), (a2_neg, a2_mag), (b2_neg, b2_mag)] = GLV_BASIS; + let a1 = Signed::new(a1_neg, limbs_to_wide(a1_mag)); + let b1 = Signed::new(b1_neg, limbs_to_wide(b1_mag)); + let a2 = Signed::new(a2_neg, limbs_to_wide(a2_mag)); + let b2 = Signed::new(b2_neg, limbs_to_wide(b2_mag)); + + let k_s = Signed::new(false, limbs_to_wide(k)); + let c1 = b2.mul(k_s).div_round(n); + let c2 = b1.negate().mul(k_s).div_round(n); + let k1 = k_s.sub(c1.mul(a1)).sub(c2.mul(a2)); + let k2 = c1.negate().mul(b1).sub(c2.mul(b2)); + + [(k1.neg, wide_to_limbs(k1.mag)), (k2.neg, wide_to_limbs(k2.mag))] +} + +/// Computes `a * b mod n`, the secp256k1 scalar-field order. +pub fn scalar_mul_mod_n(a: Limbs, b: Limbs) -> Limbs { + let n = limbs_to_wide(K1Scalar::MODULUS); + wide_to_limbs((limbs_to_wide(a) * limbs_to_wide(b)) % n) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn limbs_from_u64(v: u64) -> Limbs { + [v as u32, (v >> 32) as u32, 0, 0, 0, 0, 0, 0] + } + + /// Recomposes a GLV split via wide (unreduced) arithmetic and checks it lands back on `k` + /// modulo `n` — the property the in-circuit recompose certificate re-checks per signature. + fn recompose(split: [(bool, Limbs); 2]) -> Wide { + let n = limbs_to_wide(K1Scalar::MODULUS); + let lambda = limbs_to_wide(SECP256K1_LAMBDA); + let to_signed = |(neg, mag): (bool, Limbs)| Signed::new(neg, limbs_to_wide(mag)); + let a = to_signed(split[0]); + let b = to_signed(split[1]); + let term = Signed::new(false, lambda).mul(b); + let sum = a.add(term); + // Reduce the signed sum mod n into [0, n). + let mag_mod_n = sum.mag % n; + if sum.neg && mag_mod_n != wide_zero() { + n - mag_mod_n + } else { + mag_mod_n + } + } + + /// Re-derives the GLV short lattice basis from `(n, λ)` via a half extended-Euclid + /// shortest-lattice-vector reduction followed by a norm comparison between the two candidate + /// short vectors (Hankerson–Menezes–Vanstone, Algorithm 3.74). + #[test] + fn glv_basis_matches_extended_euclid_reduction() { + let n = limbs_to_wide(K1Scalar::MODULUS); + let lambda = limbs_to_wide(SECP256K1_LAMBDA); + + let below_sqrt_n = |r: Wide| r * r < n; + let (mut r0, mut r1) = (n, lambda); + let (mut t0, mut t1) = (Signed::new(false, wide_zero()), Signed::new(false, wide_one())); + while !below_sqrt_n(r1) { + let q = r0 / r1; + let r2 = r0 - q * r1; + let t2 = t0.sub(Signed::new(false, q).mul(t1)); + (r0, r1, t0, t1) = (r1, r2, t1, t2); + } + + let (a1, b1) = (Signed::new(false, r1), t1.negate()); + let q = r0 / r1; + let r2 = r0 - q * r1; + let t2 = t0.sub(Signed::new(false, q).mul(t1)); + let norm = |r: Wide, t: Wide| r * r + t * t; + let (a2, b2) = if norm(r0, t0.mag) <= norm(r2, t2.mag) { + (Signed::new(false, r0), t0.negate()) + } else { + (Signed::new(false, r2), t2.negate()) + }; + + let derived = [a1, b1, a2, b2].map(|s| (s.neg, wide_to_limbs(s.mag))); + assert_eq!( + derived, GLV_BASIS, + "GLV_BASIS is stale relative to the extended-Euclid reduction of (n, lambda)" + ); + } + + #[test] + fn glv_decompose_recomposes_small_scalars() { + for k in [0u64, 1, 2, 12345, u64::MAX] { + let split = glv_decompose(limbs_from_u64(k)); + assert_eq!(recompose(split), limbs_to_wide(limbs_from_u64(k)), "failed for k={k}"); + } + } + + #[test] + fn glv_decompose_recomposes_full_width_scalar() { + let k: Limbs = [ + 0x12345678, 0x9abcdef0, 0x0fedcba9, 0x87654321, 0x11223344, 0x55667788, 0x99aabbcc, + 0x00112233, + ]; + let split = glv_decompose(k); + assert_eq!(recompose(split), limbs_to_wide(k)); + } + + #[test] + fn glv_decompose_halves_are_short() { + // The shortest-vector reduction should keep both magnitudes comfortably under the full + // 256-bit scalar width -- otherwise the split buys no ladder-height win at all. + let k: Limbs = [ + 0x12345678, 0x9abcdef0, 0x0fedcba9, 0x87654321, 0x11223344, 0x55667788, 0x99aabbcc, + 0x00112233, + ]; + // 2^132: comfortably above the ~128-bit halves, comfortably below the full 256 bits. + let mut bound_limbs = [0u32; 8]; + bound_limbs[4] = 0x10; + let bound = limbs_to_wide(bound_limbs); + for (_, mag) in glv_decompose(k) { + assert!(limbs_to_wide(mag) < bound, "GLV half is not short: {mag:?}"); + } + } + + /// The orbit is what MASM compares a public key's x-coordinate against to spot a GLV split + /// that would repeat a base, so it must be exactly `G_x` under repeated multiplication by + /// `beta`, and it must close after three steps (`beta^3 = 1`). + #[test] + fn generator_x_phi_orbit_is_the_beta_orbit_of_the_generator() { + let orbit = generator_x_phi_orbit(); + let beta_times = |x| UintDomain::K1Base.mul(SECP256K1_BETA, x); + + assert_eq!(orbit[0], SECP256K1_GENERATOR_X); + assert_eq!(orbit[1], beta_times(orbit[0])); + assert_eq!(orbit[2], beta_times(orbit[1])); + assert_eq!(beta_times(orbit[2]), orbit[0], "beta^3 = 1 must close the orbit"); + + let CurvePoint::Affine { x: phi_gx, .. } = phi_generator() else { + panic!("phi(G) is an affine point"); + }; + assert_eq!(orbit[1], phi_gx, "the orbit's second element is phi(G)'s x-coordinate"); + + assert!(orbit[0] != orbit[1] && orbit[1] != orbit[2] && orbit[0] != orbit[2]); + } +} diff --git a/precompiles/src/math/curve/mod.rs b/precompiles/src/math/curve/mod.rs index 894ea9de49..26e3e164f7 100644 --- a/precompiles/src/math/curve/mod.rs +++ b/precompiles/src/math/curve/mod.rs @@ -36,10 +36,11 @@ //! This precompile does not provide compressed point encodings, subgroup checks, signature //! semantics, or public API stability guarantees beyond this internal precompile contract. +mod glv; mod secp256k1; mod short_weierstrass; -use alloc::vec::Vec; +use alloc::{vec, vec::Vec}; use miden_core::{ Felt, ZERO, @@ -50,13 +51,22 @@ use miden_core::{ }; use self::secp256k1::Secp256k1; -pub use self::secp256k1::{SECP256K1_GENERATOR_X, SECP256K1_GENERATOR_Y, SECP256K1_ID}; +pub use self::{ + glv::{SECP256K1_BETA, SECP256K1_LAMBDA, glv_decompose, phi_generator, scalar_mul_mod_n}, + secp256k1::{SECP256K1_GENERATOR_X, SECP256K1_GENERATOR_Y, SECP256K1_ID}, +}; use crate::math::uint::{Limbs, UintDomain, UintPrecompile, UintSpec}; /// VM-owned store pointer for the secp256k1 curve coefficient `A`. pub const K1_A_PTR: u32 = 8; /// VM-owned store pointer for the secp256k1 curve coefficient `B`. pub const K1_B_PTR: u32 = 9; +/// VM-owned store pointer for the secp256k1 GLV endomorphism base-field constant `β` +/// (interned under the base-field bound). +pub const K1_BETA_PTR: u32 = 10; +/// VM-owned store pointer for the secp256k1 GLV endomorphism scalar `λ` +/// (interned under the scalar-field bound). +pub const K1_LAMBDA_PTR: u32 = 11; /// VM-owned store pointer for the secp256k1 group configuration. pub const K1_GROUP_PTR: u32 = 1; @@ -90,6 +100,22 @@ pub fn curve_coefficients() -> [CurveCoefficient; 2] { ] } +/// A curve's fixed GLV endomorphism data: the base-field constant `β` with `φ(x, y) = (β·x, y)`, +/// and the scalar `λ` with `φ(P) = λ·P`. Both are VM-owned, protocol-fixed values — `β` interned +/// under the curve's base-field bound, `λ` under its scalar-field bound — so the AIR can pin a +/// claimed relation to them by pointer, never learning (or trusting a prover for) their values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Endomorphism { + /// VM-owned pointer for `β`, under the curve's base-field bound. + pub beta_ptr: u32, + /// Canonical value of `β`, little-endian u32 limbs. + pub beta: Limbs, + /// VM-owned pointer for `λ`, under the curve's scalar-field bound. + pub lambda_ptr: u32, + /// Canonical value of `λ`, little-endian u32 limbs. + pub lambda: Limbs, +} + /// Curve-generic point value. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CurvePoint { @@ -327,6 +353,52 @@ impl CurveId { } } + /// Returns named fixed point constants for this curve beyond identity/generator, e.g. a GLV + /// endomorphism image -- empty for curves with no such fixed derived points. + /// + /// Every entry here needs both (a) seeding into [`CurvePrecompile::init`]'s initial + /// deferred-DAG node set, so the point's digest is resolvable with no live registration, and + /// (b) a baked-in MASM constant/wrapper proc, emitted by `crates/lib/core/codegen`. Both + /// consumers read this single list so they cannot drift apart. + pub fn extra_points(self) -> Vec<(&'static str, CurvePoint)> { + match self { + Self::Secp256k1 => vec![("PHI_GENERATOR", phi_generator())], + } + } + + /// Returns this curve's fixed GLV endomorphisms -- empty for curves with none. A slice, not + /// an `Option`: a higher-dimensional GLV curve would list several, each merging into the same + /// base's term the same way one does. + pub fn endomorphisms(self) -> &'static [Endomorphism] { + match self { + Self::Secp256k1 => &[Endomorphism { + beta_ptr: K1_BETA_PTR, + beta: SECP256K1_BETA, + lambda_ptr: K1_LAMBDA_PTR, + lambda: SECP256K1_LAMBDA, + }], + } + } + + /// Returns named fixed base-field constants whose canonical VALUE digests the generated MASM + /// needs as comparison literals -- empty for curves with no such constants. + /// + /// Unlike [`Self::extra_points`], these are never precompile operands: MASM only compares a + /// runtime coordinate digest against them, so they are deliberately *not* seeded into + /// [`CurvePrecompile::init`] and cost nothing outside the generated constant table. + pub fn extra_base_constants(self) -> Vec<(&'static str, Limbs)> { + match self { + Self::Secp256k1 => { + let [gx, beta_gx, beta2_gx] = glv::generator_x_phi_orbit(); + vec![ + ("GENERATOR_X", gx), + ("PHI_GENERATOR_X", beta_gx), + ("PHI2_GENERATOR_X", beta2_gx), + ] + }, + } + } + /// Checked boundary dispatcher that constructs this curve's canonical point for affine /// coordinates. pub fn point_from_affine(self, x: Limbs, y: Limbs) -> Result { @@ -772,6 +844,9 @@ impl Precompile for CurvePrecompile { for curve in CurveId::ALL { nodes.push(Self::identity_node(curve)); Self::extend_init_nodes_with_point(&mut nodes, curve, curve.generator()); + for (_, point) in curve.extra_points() { + Self::extend_init_nodes_with_point(&mut nodes, curve, point); + } } nodes }