From 1d3a3900546374899faffd8429bd1d0ab39c02a6 Mon Sep 17 00:00:00 2001 From: Robin Salen Date: Mon, 3 Aug 2026 17:09:42 +0200 Subject: [PATCH] perf(precompiles): add support for GLV strategy Move 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 (phi(P) proven via its value relation, lambda pinned by pointer, never an AIR constant). This also cleans up the CHANGELOG duplication left by the earlier merge with next, and drops the now-dead scalar_inv_mod_n/reduce_mod_n helpers. --- CHANGELOG.md | 2 + Cargo.lock | 2 + crates/lib/core/Cargo.toml | 1 + .../asm/crypto/dsa/ecdsa_k256_keccak.masm | 87 +++-- crates/lib/core/codegen/src/masm.rs | 36 ++ .../core/codegen/src/templates/curve.masm.tpl | 2 + crates/lib/core/src/dsa.rs | 10 +- crates/lib/core/tests/crypto/dsa.rs | 77 ++++- .../src/deferred/session.rs | 98 +++++- crates/precompiles-prover/src/ec/add/mod.rs | 11 +- crates/precompiles-prover/src/ec/add/trace.rs | 16 +- crates/precompiles-prover/src/ec/groups.rs | 10 +- crates/precompiles-prover/src/ec/mod.rs | 27 +- crates/precompiles-prover/src/ec/msm/mod.rs | 160 ++++++++- .../precompiles-prover/src/ec/msm/require.rs | 51 ++- crates/precompiles-prover/src/ec/msm/trace.rs | 238 +++++++++++++- crates/precompiles-prover/src/ec/require.rs | 3 + crates/precompiles-prover/src/ec/trace.rs | 45 ++- .../precompiles-prover/src/session/fixed.rs | 30 +- crates/precompiles-prover/src/session/mod.rs | 8 + .../src/session/strategies.rs | 157 ++++++++- ...e_relation_digest_matches_current_air.snap | 9 +- crates/precompiles-prover/src/stark_config.rs | 8 +- crates/precompiles-prover/src/tests/ec_msm.rs | 129 +++++++- crates/precompiles/Cargo.toml | 3 +- .../precompiles_bench/input_generation.rs | 9 +- crates/precompiles/src/lib.rs | 5 +- crates/precompiles/src/math/curve/glv.rs | 309 ++++++++++++++++++ crates/precompiles/src/math/curve/mod.rs | 79 ++++- 29 files changed, 1482 insertions(+), 140 deletions(-) create mode 100644 crates/precompiles/src/math/curve/glv.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b6fc0bd5b..bf8bbbc0a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,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 f563bfa8cd..1bac281b1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2214,6 +2214,7 @@ dependencies = [ "miden-mast-package", "miden-package-registry", "miden-precompiles", + "miden-precompiles-prover", "miden-processor", "miden-test-utils", "miden-utils-sync", @@ -2516,6 +2517,7 @@ version = "0.30.0" dependencies = [ "miden-core", "miden-crypto", + "ruint", ] [[package]] diff --git a/crates/lib/core/Cargo.toml b/crates/lib/core/Cargo.toml index bd78fcb122..0708184eaa 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 d8b1980fd5..7428a3255d 100644 --- a/crates/lib/core/codegen/src/templates/curve.masm.tpl +++ b/crates/lib/core/codegen/src/templates/curve.masm.tpl @@ -46,6 +46,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, ...] @@ -130,6 +131,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 95281b91ae..39ee0f8927 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}, @@ -20,7 +21,7 @@ use miden_utils_testing::crypto::Poseidon2; use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; // Core invokes the separately packaged precompile wrappers through dynamic MAST calls. -const VERIFY_EXPECTED_CYCLES: u64 = 1_587; +const VERIFY_EXPECTED_CYCLES: u64 = 1_609; const VERIFY_EXPECTED_WIRE_ENTRIES: usize = 36; const VERIFY_EXPECTED_WIRE_BYTES: usize = 2_455; @@ -36,12 +37,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] @@ -64,7 +114,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"); } @@ -171,17 +221,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/crates/precompiles-prover/src/deferred/session.rs b/crates/precompiles-prover/src/deferred/session.rs index 4a8d542ee6..32daac3a5b 100644 --- a/crates/precompiles-prover/src/deferred/session.rs +++ b/crates/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/crates/precompiles-prover/src/ec/add/mod.rs b/crates/precompiles-prover/src/ec/add/mod.rs index 03589b204b..0c5af3e318 100644 --- a/crates/precompiles-prover/src/ec/add/mod.rs +++ b/crates/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/crates/precompiles-prover/src/ec/add/trace.rs b/crates/precompiles-prover/src/ec/add/trace.rs index 0ada006afe..af751de910 100644 --- a/crates/precompiles-prover/src/ec/add/trace.rs +++ b/crates/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/crates/precompiles-prover/src/ec/groups.rs b/crates/precompiles-prover/src/ec/groups.rs index 6fd1d0faef..e8ea87cc72 100644 --- a/crates/precompiles-prover/src/ec/groups.rs +++ b/crates/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). pub(crate) const NUM_LOGUP_COLS: usize = 1; @@ -205,6 +211,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/crates/precompiles-prover/src/ec/mod.rs b/crates/precompiles-prover/src/ec/mod.rs index 5f13e178cf..44a7d9739f 100644 --- a/crates/precompiles-prover/src/ec/mod.rs +++ b/crates/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 @@ -346,6 +357,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 @@ -402,6 +415,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/crates/precompiles-prover/src/ec/msm/mod.rs b/crates/precompiles-prover/src/ec/msm/mod.rs index 096976a391..85575971a5 100644 --- a/crates/precompiles-prover/src/ec/msm/mod.rs +++ b/crates/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/crates/precompiles-prover/src/ec/msm/require.rs b/crates/precompiles-prover/src/ec/msm/require.rs index 1f87b2ac7b..97eec999ec 100644 --- a/crates/precompiles-prover/src/ec/msm/require.rs +++ b/crates/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/crates/precompiles-prover/src/ec/msm/trace.rs b/crates/precompiles-prover/src/ec/msm/trace.rs index 259de9baa8..5a76e68ca7 100644 --- a/crates/precompiles-prover/src/ec/msm/trace.rs +++ b/crates/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/crates/precompiles-prover/src/ec/require.rs b/crates/precompiles-prover/src/ec/require.rs index 840821cf9f..75cdcb7c03 100644 --- a/crates/precompiles-prover/src/ec/require.rs +++ b/crates/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/crates/precompiles-prover/src/ec/trace.rs b/crates/precompiles-prover/src/ec/trace.rs index e3d9724df4..4968879c40 100644 --- a/crates/precompiles-prover/src/ec/trace.rs +++ b/crates/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. @@ -372,6 +400,8 @@ pub(crate) fn groups_trace_padded_to( 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)); } @@ -398,6 +428,9 @@ pub(crate) 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/crates/precompiles-prover/src/session/fixed.rs b/crates/precompiles-prover/src/session/fixed.rs index cb065d78dc..59be43ed99 100644 --- a/crates/precompiles-prover/src/session/fixed.rs +++ b/crates/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/crates/precompiles-prover/src/session/mod.rs b/crates/precompiles-prover/src/session/mod.rs index 2a7f4159eb..42bf553f2b 100644 --- a/crates/precompiles-prover/src/session/mod.rs +++ b/crates/precompiles-prover/src/session/mod.rs @@ -310,6 +310,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/crates/precompiles-prover/src/session/strategies.rs b/crates/precompiles-prover/src/session/strategies.rs index dca3045221..dc95f5f351 100644 --- a/crates/precompiles-prover/src/session/strategies.rs +++ b/crates/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/crates/precompiles-prover/src/snapshots/miden_precompiles_prover__stark_config__tests__precompile_relation_digest_matches_current_air.snap b/crates/precompiles-prover/src/snapshots/miden_precompiles_prover__stark_config__tests__precompile_relation_digest_matches_current_air.snap index 92ff8152a9..98431b727f 100644 --- a/crates/precompiles-prover/src/snapshots/miden_precompiles_prover__stark_config__tests__precompile_relation_digest_matches_current_air.snap +++ b/crates/precompiles-prover/src/snapshots/miden_precompiles_prover__stark_config__tests__precompile_relation_digest_matches_current_air.snap @@ -1,8 +1,9 @@ --- source: crates/precompiles-prover/src/stark_config.rs +assertion_line: 198 expression: snapshot --- -num_inputs: 3072 -num_eval_gates: 9576 -stream_len: 12592 -relation_digest: [14908530032554120817, 6253902297572432860, 17042470880692173314, 1364863901074227591] +num_inputs: 3104 +num_eval_gates: 9688 +stream_len: 12704 +relation_digest: [3077183552914900088, 3413333010039164498, 8142685882183338816, 4483408415715481879] diff --git a/crates/precompiles-prover/src/stark_config.rs b/crates/precompiles-prover/src/stark_config.rs index dc4b1edb29..a2474c48d1 100644 --- a/crates/precompiles-prover/src/stark_config.rs +++ b/crates/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(14908530032554120817), - Felt::new_unchecked(6253902297572432860), - Felt::new_unchecked(17042470880692173314), - Felt::new_unchecked(1364863901074227591), + Felt::new_unchecked(3077183552914900088), + Felt::new_unchecked(3413333010039164498), + Felt::new_unchecked(8142685882183338816), + Felt::new_unchecked(4483408415715481879), ]; /// Default hash function for compatibility APIs such as /// [`SessionTraces::prove`](crate::session::SessionTraces::prove). diff --git a/crates/precompiles-prover/src/tests/ec_msm.rs b/crates/precompiles-prover/src/tests/ec_msm.rs index 7f5dea4878..da735de4ac 100644 --- a/crates/precompiles-prover/src/tests/ec_msm.rs +++ b/crates/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/crates/precompiles/Cargo.toml b/crates/precompiles/Cargo.toml index 8206de8e50..0e6197e74b 100644 --- a/crates/precompiles/Cargo.toml +++ b/crates/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/crates/precompiles/benches/precompiles_bench/input_generation.rs b/crates/precompiles/benches/precompiles_bench/input_generation.rs index f156c8c10c..ddb3a2964b 100644 --- a/crates/precompiles/benches/precompiles_bench/input_generation.rs +++ b/crates/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_stack(advice_stack) } diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 2b5d7a861e..6e5d1702f3 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/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/crates/precompiles/src/math/curve/glv.rs b/crates/precompiles/src/math/curve/glv.rs new file mode 100644 index 0000000000..5b192b6d57 --- /dev/null +++ b/crates/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/crates/precompiles/src/math/curve/mod.rs b/crates/precompiles/src/math/curve/mod.rs index 894ea9de49..26e3e164f7 100644 --- a/crates/precompiles/src/math/curve/mod.rs +++ b/crates/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 }