From 012b43312e4a592161c4521fecce9573d6dd7ac9 Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 12:48:22 +0900 Subject: [PATCH 01/18] meter SHA-512 in Mollusk harness --- program/tests/mollusk.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/program/tests/mollusk.rs b/program/tests/mollusk.rs index 88f1fc7..a4f081b 100644 --- a/program/tests/mollusk.rs +++ b/program/tests/mollusk.rs @@ -47,7 +47,7 @@ declare_builtin_function!( /// live on mainnet and available in the local Agave/Mollusk runtime. SyscallSha512, fn rust( - _invoke_context: &mut InvokeContext, + invoke_context: &mut InvokeContext, vals_addr: u64, vals_len: u64, result_addr: u64, @@ -55,12 +55,19 @@ declare_builtin_function!( _arg5: u64, memory_mapping: &mut MemoryMapping, ) -> Result> { + let compute_cost = *invoke_context.get_execution_cost(); + invoke_context.consume_checked(compute_cost.sha256_base_cost)?; + let vals = translate_slice::(memory_mapping, vals_addr, vals_len, AccessType::Load)? .to_vec(); let mut hasher = solana_sha512_hasher::Hasher::default(); for val in &vals { let bytes = translate_slice::(memory_mapping, val.ptr, val.len, AccessType::Load)?; + let byte_cost = compute_cost + .sha256_byte_cost + .saturating_mul(val.len / 2); + invoke_context.consume_checked(compute_cost.mem_op_base_cost.max(byte_cost))?; hasher.hash(bytes); } From 6ccf9590c6ebe29d8932ec202785d5f806701cfa Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 13:04:38 +0900 Subject: [PATCH 02/18] Check exact errors in Mollusk tests --- program/tests/mollusk.rs | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/program/tests/mollusk.rs b/program/tests/mollusk.rs index a4f081b..fce9fd5 100644 --- a/program/tests/mollusk.rs +++ b/program/tests/mollusk.rs @@ -1,6 +1,7 @@ use { ed25519_dalek::{Signer, SigningKey}, - mollusk_svm::Mollusk, + mollusk_svm::{result::ProgramResult, Mollusk}, + pinocchio::error::ProgramError, solana_account::Account, solana_address::Address, solana_ed25519_verify::{ @@ -218,10 +219,9 @@ fn rejects_tampered_message_on_sbf() { ix.data[MESSAGE_OFFSET] ^= 1; let result = mollusk.process_instruction(&ix, &[]); - assert!( - result.program_result.is_err(), - "expected failure on tampered message, got: {:?}", - result.program_result + assert_eq!( + result.program_result, + ProgramResult::Failure(ProgramError::InvalidInstructionData), ); } @@ -234,10 +234,9 @@ fn rejects_tampered_public_key_on_sbf() { ix.data[PUBLIC_KEY_OFFSET] ^= 1; let result = mollusk.process_instruction(&ix, &[]); - assert!( - result.program_result.is_err(), - "expected failure on tampered public key, got: {:?}", - result.program_result + assert_eq!( + result.program_result, + ProgramResult::Failure(ProgramError::InvalidInstructionData), ); } @@ -252,10 +251,9 @@ fn rejects_accounts_on_sbf() { let accounts = [(account, Account::default())]; let result = mollusk.process_instruction(&ix, &accounts); - assert!( - result.program_result.is_err(), - "expected failure when accounts are provided, got: {:?}", - result.program_result + assert_eq!( + result.program_result, + ProgramResult::Failure(ProgramError::InvalidArgument), ); } @@ -267,9 +265,8 @@ fn rejects_short_instruction_on_sbf() { let ix = instruction(program_id, vec![0; MESSAGE_OFFSET - 1]); let result = mollusk.process_instruction(&ix, &[]); - assert!( - result.program_result.is_err(), - "expected failure on short instruction data, got: {:?}", - result.program_result + assert_eq!( + result.program_result, + ProgramResult::Failure(ProgramError::InvalidInstructionData), ); } From 8730b6813e23df88dc43b5809466f2ca1a5762a8 Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 13:07:36 +0900 Subject: [PATCH 03/18] Add deterministic SBF verification benchmarks --- program/tests/mollusk.rs | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/program/tests/mollusk.rs b/program/tests/mollusk.rs index fce9fd5..0bbba3b 100644 --- a/program/tests/mollusk.rs +++ b/program/tests/mollusk.rs @@ -270,3 +270,52 @@ fn rejects_short_instruction_on_sbf() { ProgramResult::Failure(ProgramError::InvalidInstructionData), ); } + +#[test] +fn verifies_signature_corpus_on_sbf_and_reports_compute_units() { + let Some((mollusk, program_id)) = make_mollusk() else { + return; + }; + + let mut cases = 0u64; + let mut total_cus = 0u64; + let mut min_cus = u64::MAX; + let mut max_cus = 0u64; + + for seed in [7u8, 42, 99, 201] { + let signing_key = SigningKey::from_bytes(&[seed; 32]); + let public_key = signing_key.verifying_key().to_bytes(); + + for message_len in [0usize, 1, 38, 47, 48, 49, 128, 1024] { + let message = if message_len == SINGLE_MESSAGE.len() { + SINGLE_MESSAGE.to_vec() + } else { + (0..message_len) + .map(|index| (index as u8).wrapping_mul(31).wrapping_add(17)) + .collect::>() + }; + let signature = signing_key.sign(&message).to_bytes(); + let ix = verify(&program_id, &public_key, &signature, &message); + + let result = mollusk.process_instruction(&ix, &[]); + assert_eq!( + result.program_result, + ProgramResult::Success, + "corpus verification failed: seed={seed}, message_len={message_len}", + ); + + let cus = result.compute_units_consumed; + println!("ed25519 corpus: seed={seed}, message_bytes={message_len}, CUs={cus}"); + + cases += 1; + total_cus += cus; + min_cus = min_cus.min(cus); + max_cus = max_cus.max(cus); + } + } + + println!( + "ed25519 corpus summary: cases={cases}, total_CUs={total_cus}, \ + min_CUs={min_cus}, max_CUs={max_cus}" + ); +} From b3af97a18dfbccf2bafae4310f9c5fac94dfe7fa Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 13:12:50 +0900 Subject: [PATCH 04/18] Expand scalar reduction reference tests --- ed25519-verify/src/scalar.rs | 69 ++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/ed25519-verify/src/scalar.rs b/ed25519-verify/src/scalar.rs index 3e835e0..e24ded5 100644 --- a/ed25519-verify/src/scalar.rs +++ b/ed25519-verify/src/scalar.rs @@ -141,6 +141,75 @@ mod tests { assert_eq!(reduce_wide(&saturated), expected); } + #[test] + fn matches_dalek_on_wide_reduction_boundaries_and_random_inputs() { + fn check(wide: &[u8; 64]) { + let expected = + curve25519_dalek::scalar::Scalar::from_bytes_mod_order_wide(wide).to_bytes(); + assert_eq!(reduce_wide(wide), expected, "wide input: {wide:02x?}"); + } + + fn check_neighbors(wide: [u8; 64]) { + check(&wide); + + let mut below = wide; + for byte in &mut below { + let (value, borrow) = byte.overflowing_sub(1); + *byte = value; + if !borrow { + break; + } + } + check(&below); + + let mut above = wide; + for byte in &mut above { + let (value, carry) = byte.overflowing_add(1); + *byte = value; + if !carry { + break; + } + } + check(&above); + } + + check(&[0; 64]); + check(&[0xff; 64]); + + // Powers of two and their neighbors across every bit position. + for bit in 0..512usize { + let mut wide = [0u8; 64]; + wide[bit / 8] = 1u8 << (bit % 8); + check_neighbors(wide); + } + + // L * 2^shift and its neighbors, through the highest fitting shift. + let mut shifted_order = wide_from_low_32(&BASEPOINT_ORDER); + for _ in 0..=259 { + check_neighbors(shifted_order); + + let mut carry = 0u8; + for byte in &mut shifted_order { + let next_carry = *byte >> 7; + *byte = (*byte << 1) | carry; + carry = next_carry; + } + } + + // Deterministic test inputs; this PRNG is not used by the verifier. + let mut state = 0x9e37_79b9_7f4a_7c15u64; + for _ in 0..4096 { + let mut wide = [0u8; 64]; + for chunk in wide.chunks_exact_mut(8) { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + chunk.copy_from_slice(&state.to_le_bytes()); + } + check(&wide); + } + } + #[test] fn accepts_reduced_encodings() { // y = 0 From 368217bb981e4204416338ca255c6601468bf4f1 Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 13:22:31 +0900 Subject: [PATCH 05/18] Use Barrett reduction for Ed25519 challenges --- ed25519-verify/src/scalar.rs | 81 ++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/ed25519-verify/src/scalar.rs b/ed25519-verify/src/scalar.rs index e24ded5..d6ce205 100644 --- a/ed25519-verify/src/scalar.rs +++ b/ed25519-verify/src/scalar.rs @@ -16,21 +16,69 @@ pub(crate) fn is_canonical_point_encoding(encoding: &[u8; 32]) -> bool { cmp_le(&y, &FIELD_MODULUS).is_lt() } -/// Reduces a 64-byte little-endian integer modulo the ed25519 base point order. +/// Reduces a 64-byte little-endian integer modulo the Ed25519 group order. /// -/// Bit-serial long division: shift the remainder left, shift in the next bit of -/// the dividend, and subtract `L` whenever the result reaches it. The remainder -/// is carried as 64-bit limbs rather than bytes, so each step touches four words -/// instead of thirty-two. +/// Uses Barrett reduction with mu = floor(2^512 / L). The quotient estimate +/// is at most one below the true quotient, leaving a remainder below 2*L. pub(crate) fn reduce_wide(wide: &[u8; 64]) -> [u8; 32] { - let mut remainder = [0u64; 4]; + const L: [u32; 8] = [ + BASEPOINT_ORDER_LIMBS[0] as u32, + (BASEPOINT_ORDER_LIMBS[0] >> 32) as u32, + BASEPOINT_ORDER_LIMBS[1] as u32, + (BASEPOINT_ORDER_LIMBS[1] >> 32) as u32, + BASEPOINT_ORDER_LIMBS[2] as u32, + (BASEPOINT_ORDER_LIMBS[2] >> 32) as u32, + BASEPOINT_ORDER_LIMBS[3] as u32, + (BASEPOINT_ORDER_LIMBS[3] >> 32) as u32, + ]; + const MU: [u32; 9] = [ + 0x0a2c131b, 0xed9ce5a3, 0x086329a7, 0x2106215d, 0xffffffeb, 0xffffffff, 0xffffffff, + 0xffffffff, 0x0000000f, + ]; + + let mut x = [0u32; 16]; + for (limb, bytes) in x.iter_mut().zip(wide.chunks_exact(4)) { + *limb = u32::from_le_bytes(bytes.try_into().unwrap()); + } + + // Compute x * mu. Its limbs starting at index 16 contain floor(x*mu/2^512). + let mut product = [0u32; 25]; + for i in 0..16 { + let mut carry = 0u64; + for j in 0..9 { + let acc = u64::from(x[i]) * u64::from(MU[j]) + u64::from(product[i + j]) + carry; + product[i + j] = acc as u32; + carry = acc >> 32; + } + // Earlier rows have not touched this limb. + product[i + 9] = carry as u32; + } - for bit_index in (0..512).rev() { - shl1(&mut remainder); - remainder[0] |= u64::from((wide[bit_index / 8] >> (bit_index % 8)) & 1); - conditional_sub_order(&mut remainder); + // Compute q*L modulo 2^256. Higher quotient limbs cannot affect these bits. + let mut q_l = [0u32; 8]; + for i in 0..8 { + let mut carry = 0u64; + for j in 0..(8 - i) { + let acc = u64::from(product[16 + i]) * u64::from(L[j]) + u64::from(q_l[i + j]) + carry; + q_l[i + j] = acc as u32; + carry = acc >> 32; + } } + // Recover x - q*L modulo 2^256. Since 0 <= x - q*L < 2*L < 2^256, + // these low bits contain the entire remainder. + let mut remainder = [0u64; 4]; + let mut borrow = 0u64; + for i in 0..8 { + let difference = u64::from(x[i]) + .wrapping_sub(u64::from(q_l[i])) + .wrapping_sub(borrow); + remainder[i / 2] |= u64::from(difference as u32) << ((i % 2) * 32); + borrow = difference >> 63; + } + + conditional_sub_order(&mut remainder); + let mut reduced = [0u8; 32]; for (chunk, limb) in reduced.chunks_exact_mut(8).zip(remainder) { chunk.copy_from_slice(&limb.to_le_bytes()); @@ -38,19 +86,6 @@ pub(crate) fn reduce_wide(wide: &[u8; 64]) -> [u8; 32] { reduced } -/// Doubles `value` in place, discarding the bit shifted out of the top. -/// -/// The caller keeps `value < L < 2^253` between iterations, so the discarded bit -/// is always zero. -fn shl1(value: &mut [u64; 4]) { - let mut carry = 0u64; - for limb in value { - let next_carry = *limb >> 63; - *limb = (*limb << 1) | carry; - carry = next_carry; - } -} - /// Subtracts `L` from `value` when `value >= L`, without branching on the input. fn conditional_sub_order(value: &mut [u64; 4]) { let mut difference = [0u64; 4]; From 0eddd88f74211e83c1257053e2eee1c13dbcf9e6 Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 13:32:02 +0900 Subject: [PATCH 06/18] Unroll Barrett reciprocal multiplication --- ed25519-verify/src/scalar.rs | 37 +++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/ed25519-verify/src/scalar.rs b/ed25519-verify/src/scalar.rs index d6ce205..d5b1bbd 100644 --- a/ed25519-verify/src/scalar.rs +++ b/ed25519-verify/src/scalar.rs @@ -43,17 +43,36 @@ pub(crate) fn reduce_wide(wide: &[u8; 64]) -> [u8; 32] { // Compute x * mu. Its limbs starting at index 16 contain floor(x*mu/2^512). let mut product = [0u32; 25]; - for i in 0..16 { - let mut carry = 0u64; - for j in 0..9 { - let acc = u64::from(x[i]) * u64::from(MU[j]) + u64::from(product[i + j]) + carry; - product[i + j] = acc as u32; - carry = acc >> 32; - } - // Earlier rows have not touched this limb. - product[i + 9] = carry as u32; + + macro_rules! mul_mu_row { + ($i:literal) => {{ + let mut carry = 0u64; + for j in 0..9 { + let acc = u64::from(x[$i]) * u64::from(MU[j]) + u64::from(product[$i + j]) + carry; + product[$i + j] = acc as u32; + carry = acc >> 32; + } + product[$i + 9] = carry as u32; + }}; } + mul_mu_row!(0); + mul_mu_row!(1); + mul_mu_row!(2); + mul_mu_row!(3); + mul_mu_row!(4); + mul_mu_row!(5); + mul_mu_row!(6); + mul_mu_row!(7); + mul_mu_row!(8); + mul_mu_row!(9); + mul_mu_row!(10); + mul_mu_row!(11); + mul_mu_row!(12); + mul_mu_row!(13); + mul_mu_row!(14); + mul_mu_row!(15); + // Compute q*L modulo 2^256. Higher quotient limbs cannot affect these bits. let mut q_l = [0u32; 8]; for i in 0..8 { From c9f2d9279e0d1ec37b38f496cfb70a69646ca63c Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 16:18:03 +0900 Subject: [PATCH 07/18] Write Ed25519 challenges directly into MSM scalars --- ed25519-verify/src/points.rs | 16 ++++++++++++++-- ed25519-verify/src/scalar.rs | 9 +++++++-- ed25519-verify/src/verifier.rs | 7 ++++--- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/ed25519-verify/src/points.rs b/ed25519-verify/src/points.rs index 1e060d3..411a2be 100644 --- a/ed25519-verify/src/points.rs +++ b/ed25519-verify/src/points.rs @@ -34,13 +34,25 @@ pub(crate) fn multiply_by_8(point: &PodEdwardsPoint) -> Option } /// Computes the Ed25519 challenge scalar `H(R || A || M) mod L`. +pub(crate) fn compute_challenge_into( + signature_r: &[u8; 32], + public_key: &[u8; 32], + message: &[u8], + challenge: &mut [u8; 32], +) { + let digest = solana_sha512_hasher::hashv(&[signature_r, public_key, message]).to_bytes(); + scalar::reduce_wide_into(&digest, challenge); +} + +#[cfg(test)] pub(crate) fn compute_challenge( signature_r: &[u8; 32], public_key: &[u8; 32], message: &[u8], ) -> [u8; 32] { - let digest = solana_sha512_hasher::hashv(&[signature_r, public_key, message]).to_bytes(); - scalar::reduce_wide(&digest) + let mut challenge = [0u8; 32]; + compute_challenge_into(signature_r, public_key, message, &mut challenge); + challenge } #[cfg(test)] diff --git a/ed25519-verify/src/scalar.rs b/ed25519-verify/src/scalar.rs index d5b1bbd..619d816 100644 --- a/ed25519-verify/src/scalar.rs +++ b/ed25519-verify/src/scalar.rs @@ -20,7 +20,7 @@ pub(crate) fn is_canonical_point_encoding(encoding: &[u8; 32]) -> bool { /// /// Uses Barrett reduction with mu = floor(2^512 / L). The quotient estimate /// is at most one below the true quotient, leaving a remainder below 2*L. -pub(crate) fn reduce_wide(wide: &[u8; 64]) -> [u8; 32] { +pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { const L: [u32; 8] = [ BASEPOINT_ORDER_LIMBS[0] as u32, (BASEPOINT_ORDER_LIMBS[0] >> 32) as u32, @@ -98,10 +98,15 @@ pub(crate) fn reduce_wide(wide: &[u8; 64]) -> [u8; 32] { conditional_sub_order(&mut remainder); - let mut reduced = [0u8; 32]; for (chunk, limb) in reduced.chunks_exact_mut(8).zip(remainder) { chunk.copy_from_slice(&limb.to_le_bytes()); } +} + +#[cfg(test)] +pub(crate) fn reduce_wide(wide: &[u8; 64]) -> [u8; 32] { + let mut reduced = [0u8; 32]; + reduce_wide_into(wide, &mut reduced); reduced } diff --git a/ed25519-verify/src/verifier.rs b/ed25519-verify/src/verifier.rs index 4f30465..58f907b 100644 --- a/ed25519-verify/src/verifier.rs +++ b/ed25519-verify/src/verifier.rs @@ -5,7 +5,7 @@ use { PUBKEY_SERIALIZED_SIZE, SIGNATURE_SERIALIZED_SIZE, }, error::Ed25519VerifyError, - points::{compute_challenge, is_small_order, multiply_by_8}, + points::{compute_challenge_into, is_small_order, multiply_by_8}, scalar, VerificationCriteria, }, solana_curve25519::{ @@ -86,12 +86,13 @@ impl Ed25519Verifier { return Err(Ed25519VerifyError::SmallOrderR); } - let challenge = compute_challenge(r_bytes, public_key, message); + let mut scalars = [PodScalar(*s_bytes), PodScalar([0u8; 32])]; + compute_challenge_into(r_bytes, public_key, message, &mut scalars[1].0); // `S*(-B) + H*A` is `-(S*B - H*A)`, the negation of the value the // verification equation compares against `R`. let neg_lhs = multiscalar_multiply_edwards( - &[PodScalar(*s_bytes), PodScalar(challenge)], + &scalars, &[ED25519_BASEPOINT_NEGATED_COMPRESSED, public_key_point], ) .ok_or(Ed25519VerifyError::InvalidEncoding)?; From 218533342b0e8968bcdbb0f4874d94dbed8adbfc Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 16:51:22 +0900 Subject: [PATCH 08/18] Simplify Barrett correction borrow --- ed25519-verify/src/scalar.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ed25519-verify/src/scalar.rs b/ed25519-verify/src/scalar.rs index 619d816..6f8582a 100644 --- a/ed25519-verify/src/scalar.rs +++ b/ed25519-verify/src/scalar.rs @@ -111,11 +111,13 @@ pub(crate) fn reduce_wide(wide: &[u8; 64]) -> [u8; 32] { } /// Subtracts `L` from `value` when `value >= L`, without branching on the input. +/// +/// Requires `value < 2*L`, as guaranteed by the Barrett reduction. fn conditional_sub_order(value: &mut [u64; 4]) { let mut difference = [0u64; 4]; let mut borrow = 0u64; - for index in 0..4 { + for index in 0..3 { let (partial, borrow_from_order) = value[index].overflowing_sub(BASEPOINT_ORDER_LIMBS[index]); let (limb, borrow_from_carry) = partial.overflowing_sub(borrow); @@ -123,9 +125,13 @@ fn conditional_sub_order(value: &mut [u64; 4]) { borrow = u64::from(borrow_from_order | borrow_from_carry); } - // A borrow out of the top limb means `value < L`, so the difference is - // discarded. `mask` is all-ones exactly when the subtraction should apply. - let mask = borrow.wrapping_sub(1); + difference[3] = value[3] + .wrapping_sub(BASEPOINT_ORDER_LIMBS[3]) + .wrapping_sub(borrow); + + // With value < 2*L, the signed top-limb difference lies in + // [-2^60 - 1, 2^60]. Its high bit therefore gives the final borrow. + let mask = (difference[3] >> 63).wrapping_sub(1); for index in 0..4 { value[index] = (value[index] & !mask) | (difference[index] & mask); } From e06fbe910dc9bba1a3b57b63566b90dd839a4cac Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 17:08:36 +0900 Subject: [PATCH 09/18] Use sparse order multiplication in Barrett reduction --- ed25519-verify/src/scalar.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ed25519-verify/src/scalar.rs b/ed25519-verify/src/scalar.rs index 6f8582a..175316f 100644 --- a/ed25519-verify/src/scalar.rs +++ b/ed25519-verify/src/scalar.rs @@ -73,17 +73,20 @@ pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { mul_mu_row!(14); mul_mu_row!(15); - // Compute q*L modulo 2^256. Higher quotient limbs cannot affect these bits. + // Compute q*L modulo 2^256. The middle order limbs L[4..7] are zero. let mut q_l = [0u32; 8]; - for i in 0..8 { + for j in 0..4 { let mut carry = 0u64; - for j in 0..(8 - i) { + for i in 0..(8 - j) { let acc = u64::from(product[16 + i]) * u64::from(L[j]) + u64::from(q_l[i + j]) + carry; q_l[i + j] = acc as u32; carry = acc >> 32; } } + // The high order limb contributes only to the highest retained word. + q_l[7] = q_l[7].wrapping_add(product[16].wrapping_mul(L[7])); + // Recover x - q*L modulo 2^256. Since 0 <= x - q*L < 2*L < 2^256, // these low bits contain the entire remainder. let mut remainder = [0u64; 4]; From 31778cdf88c7e09256a40281201b528a6928f8e2 Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 17:52:32 +0900 Subject: [PATCH 10/18] Inline Ed25519 signature verification --- ed25519-verify/src/verifier.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/ed25519-verify/src/verifier.rs b/ed25519-verify/src/verifier.rs index 58f907b..c2e0bd1 100644 --- a/ed25519-verify/src/verifier.rs +++ b/ed25519-verify/src/verifier.rs @@ -56,6 +56,7 @@ impl Ed25519Verifier { /// Checks `S*B - H(R || A || M)*A - R == identity`, multiplied by the /// cofactor 8 when [`VerificationCriteria::cofactored`] is set. /// Canonical-encoding and small-order rejections run first. + #[inline(always)] pub fn verify_signature( &self, signature: &[u8; SIGNATURE_SERIALIZED_SIZE], From d8af390d9f4aab9447bc5856d66f364394e123f4 Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 18:05:13 +0900 Subject: [PATCH 11/18] Use radix-21 reduction for Ed25519 challenges --- ed25519-verify/src/scalar.rs | 178 +++++++++++++++-------------------- 1 file changed, 77 insertions(+), 101 deletions(-) diff --git a/ed25519-verify/src/scalar.rs b/ed25519-verify/src/scalar.rs index 175316f..ad5d32a 100644 --- a/ed25519-verify/src/scalar.rs +++ b/ed25519-verify/src/scalar.rs @@ -18,88 +18,91 @@ pub(crate) fn is_canonical_point_encoding(encoding: &[u8; 32]) -> bool { /// Reduces a 64-byte little-endian integer modulo the Ed25519 group order. /// -/// Uses Barrett reduction with mu = floor(2^512 / L). The quotient estimate -/// is at most one below the true quotient, leaving a remainder below 2*L. +/// Uses radix-2^21 limbs and the relation 2^252 = -c (mod L), where +/// L = 2^252 + c. After folding, -L < r < L; adding L when r is negative +/// produces the canonical scalar. pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { - const L: [u32; 8] = [ - BASEPOINT_ORDER_LIMBS[0] as u32, - (BASEPOINT_ORDER_LIMBS[0] >> 32) as u32, - BASEPOINT_ORDER_LIMBS[1] as u32, - (BASEPOINT_ORDER_LIMBS[1] >> 32) as u32, - BASEPOINT_ORDER_LIMBS[2] as u32, - (BASEPOINT_ORDER_LIMBS[2] >> 32) as u32, - BASEPOINT_ORDER_LIMBS[3] as u32, - (BASEPOINT_ORDER_LIMBS[3] >> 32) as u32, - ]; - const MU: [u32; 9] = [ - 0x0a2c131b, 0xed9ce5a3, 0x086329a7, 0x2106215d, 0xffffffeb, 0xffffffff, 0xffffffff, - 0xffffffff, 0x0000000f, - ]; - - let mut x = [0u32; 16]; - for (limb, bytes) in x.iter_mut().zip(wide.chunks_exact(4)) { - *limb = u32::from_le_bytes(bytes.try_into().unwrap()); + #[inline(always)] + fn fold(limbs: &mut [i64; 24], index: usize) { + // The radix-2^21 expansion of -c. + const COEFFICIENTS: [i64; 6] = [666643, 470296, 654183, -997805, 136657, -683901]; + + let high = limbs[index]; + limbs[index] = 0; + for (j, &coefficient) in COEFFICIENTS.iter().enumerate() { + let low = index - 12 + j; + let value = limbs[low] + high * coefficient; + limbs[low] = value & 0x1f_ffff; + limbs[low + 1] += value >> 21; + } } - // Compute x * mu. Its limbs starting at index 16 contain floor(x*mu/2^512). - let mut product = [0u32; 25]; - - macro_rules! mul_mu_row { - ($i:literal) => {{ - let mut carry = 0u64; - for j in 0..9 { - let acc = u64::from(x[$i]) * u64::from(MU[j]) + u64::from(product[$i + j]) + carry; - product[$i + j] = acc as u32; - carry = acc >> 32; - } - product[$i + 9] = carry as u32; - }}; + let mut limbs = [0i64; 24]; + for i in 0..23 { + let bit = i * 21; + let byte = bit / 8; + let word = u32::from_le_bytes(wide[byte..byte + 4].try_into().unwrap()); + limbs[i] = i64::from((word >> (bit % 8)) & 0x1f_ffff); } - - mul_mu_row!(0); - mul_mu_row!(1); - mul_mu_row!(2); - mul_mu_row!(3); - mul_mu_row!(4); - mul_mu_row!(5); - mul_mu_row!(6); - mul_mu_row!(7); - mul_mu_row!(8); - mul_mu_row!(9); - mul_mu_row!(10); - mul_mu_row!(11); - mul_mu_row!(12); - mul_mu_row!(13); - mul_mu_row!(14); - mul_mu_row!(15); - - // Compute q*L modulo 2^256. The middle order limbs L[4..7] are zero. - let mut q_l = [0u32; 8]; - for j in 0..4 { - let mut carry = 0u64; - for i in 0..(8 - j) { - let acc = u64::from(product[16 + i]) * u64::from(L[j]) + u64::from(q_l[i + j]) + carry; - q_l[i + j] = acc as u32; - carry = acc >> 32; - } + limbs[23] = i64::from(u32::from_le_bytes(wide[60..64].try_into().unwrap()) >> 3); + + // Each fold normalizes its six destination limbs immediately, keeping + // every multiplication and addition within i64. + fold(&mut limbs, 23); + fold(&mut limbs, 22); + fold(&mut limbs, 21); + fold(&mut limbs, 20); + fold(&mut limbs, 19); + fold(&mut limbs, 18); + fold(&mut limbs, 17); + fold(&mut limbs, 16); + fold(&mut limbs, 15); + fold(&mut limbs, 14); + fold(&mut limbs, 13); + fold(&mut limbs, 12); + + for i in 0..12 { + let carry = limbs[i] >> 21; + limbs[i] &= 0x1f_ffff; + limbs[i + 1] += carry; } - // The high order limb contributes only to the highest retained word. - q_l[7] = q_l[7].wrapping_add(product[16].wrapping_mul(L[7])); - - // Recover x - q*L modulo 2^256. Since 0 <= x - q*L < 2*L < 2^256, - // these low bits contain the entire remainder. - let mut remainder = [0u64; 4]; - let mut borrow = 0u64; - for i in 0..8 { - let difference = u64::from(x[i]) - .wrapping_sub(u64::from(q_l[i])) - .wrapping_sub(borrow); - remainder[i / 2] |= u64::from(difference as u32) << ((i % 2) * 32); - borrow = difference >> 63; + // Here limbs[12] is in [-1, 28], and the lower twelve limbs encode + // a value below 2^252. Folding once more gives -28*c <= r < L. + // Since 28*c < L, a negative remainder needs exactly one addition of L. + fold(&mut limbs, 12); + for i in 0..11 { + let carry = limbs[i] >> 21; + limbs[i] &= 0x1f_ffff; + limbs[i + 1] += carry; } - conditional_sub_order(&mut remainder); + // Pack r modulo 2^256. The top limb retains the sign of r. + let mut remainder = [ + (limbs[0] as u64) + | ((limbs[1] as u64) << 21) + | ((limbs[2] as u64) << 42) + | ((limbs[3] as u64) << 63), + ((limbs[3] as u64) >> 1) + | ((limbs[4] as u64) << 20) + | ((limbs[5] as u64) << 41) + | ((limbs[6] as u64) << 62), + ((limbs[6] as u64) >> 2) + | ((limbs[7] as u64) << 19) + | ((limbs[8] as u64) << 40) + | ((limbs[9] as u64) << 61), + ((limbs[9] as u64) >> 3) | ((limbs[10] as u64) << 18) | ((limbs[11] as u64) << 39), + ]; + + let mask = 0u64.wrapping_sub(remainder[3] >> 63); + let mut carry = 0u64; + for i in 0..4 { + let (partial, carry_from_order) = + remainder[i].overflowing_add(BASEPOINT_ORDER_LIMBS[i] & mask); + let (limb, carry_from_carry) = partial.overflowing_add(carry); + remainder[i] = limb; + carry = u64::from(carry_from_order | carry_from_carry); + } for (chunk, limb) in reduced.chunks_exact_mut(8).zip(remainder) { chunk.copy_from_slice(&limb.to_le_bytes()); @@ -113,33 +116,6 @@ pub(crate) fn reduce_wide(wide: &[u8; 64]) -> [u8; 32] { reduced } -/// Subtracts `L` from `value` when `value >= L`, without branching on the input. -/// -/// Requires `value < 2*L`, as guaranteed by the Barrett reduction. -fn conditional_sub_order(value: &mut [u64; 4]) { - let mut difference = [0u64; 4]; - let mut borrow = 0u64; - - for index in 0..3 { - let (partial, borrow_from_order) = - value[index].overflowing_sub(BASEPOINT_ORDER_LIMBS[index]); - let (limb, borrow_from_carry) = partial.overflowing_sub(borrow); - difference[index] = limb; - borrow = u64::from(borrow_from_order | borrow_from_carry); - } - - difference[3] = value[3] - .wrapping_sub(BASEPOINT_ORDER_LIMBS[3]) - .wrapping_sub(borrow); - - // With value < 2*L, the signed top-limb difference lies in - // [-2^60 - 1, 2^60]. Its high bit therefore gives the final borrow. - let mask = (difference[3] >> 63).wrapping_sub(1); - for index in 0..4 { - value[index] = (value[index] & !mask) | (difference[index] & mask); - } -} - pub(crate) fn cmp_le(left: &[u8; 32], right: &[u8; 32]) -> core::cmp::Ordering { for index in (0..4).rev() { let left_limb = u64::from_le_bytes(left[index * 8..index * 8 + 8].try_into().unwrap()); From b6240ee0482e0bc165027460cfe9a80c566bea36 Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 18:12:57 +0900 Subject: [PATCH 12/18] Batch carry propagation in radix-21 reduction --- ed25519-verify/src/scalar.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ed25519-verify/src/scalar.rs b/ed25519-verify/src/scalar.rs index ad5d32a..7b71c73 100644 --- a/ed25519-verify/src/scalar.rs +++ b/ed25519-verify/src/scalar.rs @@ -30,10 +30,7 @@ pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { let high = limbs[index]; limbs[index] = 0; for (j, &coefficient) in COEFFICIENTS.iter().enumerate() { - let low = index - 12 + j; - let value = limbs[low] + high * coefficient; - limbs[low] = value & 0x1f_ffff; - limbs[low + 1] += value >> 21; + limbs[index - 12 + j] += high * coefficient; } } @@ -46,14 +43,22 @@ pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { } limbs[23] = i64::from(u32::from_le_bytes(wide[60..64].try_into().unwrap()) >> 3); - // Each fold normalizes its six destination limbs immediately, keeping - // every multiplication and addition within i64. + // Fold the highest six limbs before propagating their carries. fold(&mut limbs, 23); fold(&mut limbs, 22); fold(&mut limbs, 21); fold(&mut limbs, 20); fold(&mut limbs, 19); fold(&mut limbs, 18); + + // Normalize through limb 16 before folding limb 17. + // Stop before limb 18, which has already been folded. + for i in 6..17 { + let carry = limbs[i] >> 21; + limbs[i] &= 0x1f_ffff; + limbs[i + 1] += carry; + } + fold(&mut limbs, 17); fold(&mut limbs, 16); fold(&mut limbs, 15); From 60396f56dd253903d5dae474c0e3849028dbe900 Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 23:37:53 +0900 Subject: [PATCH 13/18] Use torsion lookup for ZIP-215 verification --- ed25519-verify/src/config.rs | 8 ++-- ed25519-verify/src/points.rs | 79 +++++++++++++++++++++++++++++++++- ed25519-verify/src/verifier.rs | 10 ++--- program/tests/mollusk.rs | 48 +++++++++++++++++++++ 4 files changed, 132 insertions(+), 13 deletions(-) diff --git a/ed25519-verify/src/config.rs b/ed25519-verify/src/config.rs index e55fd8e..c278bf4 100644 --- a/ed25519-verify/src/config.rs +++ b/ed25519-verify/src/config.rs @@ -44,11 +44,9 @@ pub struct VerificationCriteria { /// Use the cofactored verification equation /// `[8](S·B − H·A − R) == identity`. /// - /// When `false`, the cofactorless equation `S·B − H·A − R == identity` is - /// used, which rejects mixed-order points that the cofactored equation - /// tolerates. The cofactored form costs one extra multiplication by the - /// cofactor 8, which the verifier performs as three `sol_curve_group_op` - /// additions. + /// When `false`, the difference must be the identity. + /// When `true`, any torsion difference is accepted, using a lookup of + /// its canonical compressed encoding. pub cofactored: bool, /// Reject public keys whose compressed `y`-coordinate is `>= p` (a /// non-canonical encoding of a reduced point). diff --git a/ed25519-verify/src/points.rs b/ed25519-verify/src/points.rs index 411a2be..3bffbac 100644 --- a/ed25519-verify/src/points.rs +++ b/ed25519-verify/src/points.rs @@ -1,7 +1,6 @@ //! Edwards curve point operations built on `solana-curve25519` syscalls. //! -//! Covers the cofactor-8 multiplication used both for small-order rejection -//! and for the ZIP-215 cofactored verification equation, and the Ed25519 +//! Covers small-order checks, the ZIP-215 torsion lookup, and the Ed25519 //! challenge hash `H(R || A || M) mod L`. use { @@ -55,6 +54,37 @@ pub(crate) fn compute_challenge( challenge } +/// Tests torsion membership for a valid, canonical curve encoding. +/// +/// Call this on points produced by curve operations, not unvalidated input. +#[inline(never)] +pub(crate) fn is_small_order_canonical(point: &PodEdwardsPoint) -> bool { + const ORDER_TWO_Y: [u8; 32] = { + let mut y = [0xff; 32]; + y[0] = 0xec; + y[31] = 0x7f; + y + }; + const ORDER_EIGHT_Y0: [u8; 32] = [ + 0x26, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0, 0x45, 0xc3, 0xf4, 0x89, 0xf2, 0xef, 0x98, + 0xf0, 0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, 0x33, 0x39, 0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53, + 0xfc, 0x05, + ]; + const ORDER_EIGHT_Y1: [u8; 32] = [ + 0xc7, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f, 0xba, 0x3c, 0x0b, 0x76, 0x0d, 0x10, 0x67, + 0x0f, 0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, 0xcc, 0xc6, 0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac, + 0x03, 0x7a, + ]; + + let mut y = point.0; + y[31] &= 0x7f; + y == [0u8; 32] + || y == EDWARDS_IDENTITY_COMPRESSED.0 + || y == ORDER_TWO_Y + || y == ORDER_EIGHT_Y0 + || y == ORDER_EIGHT_Y1 +} + #[cfg(test)] mod tests { use {super::*, crate::constants::PUBKEY_SERIALIZED_SIZE, ed25519_dalek::SigningKey}; @@ -145,4 +175,49 @@ mod tests { assert_eq!(compute_challenge(&r, &a, message), expected); } + + #[test] + fn canonical_torsion_lookup_matches_dalek() { + use curve25519_dalek::{ + constants::{ED25519_BASEPOINT_POINT, EIGHT_TORSION}, + scalar::Scalar, + }; + + for i in 0..16u64 { + let prime = ED25519_BASEPOINT_POINT * Scalar::from(i); + for torsion in &EIGHT_TORSION { + let point = &prime + torsion; + let encoded = PodEdwardsPoint(point.compress().to_bytes()); + assert_eq!( + is_small_order_canonical(&encoded), + point.is_small_order(), + "prime scalar={i}" + ); + } + } + } + + #[test] + fn cofactorless_rejects_nonidentity_torsion_differences() { + let verifier = crate::Ed25519Verifier::with_criteria(crate::VerificationCriteria { + cofactored: false, + ..crate::VerificationCriteria::zip215() + }); + let public_key = EDWARDS_IDENTITY_COMPRESSED.0; + + for torsion in &curve25519_dalek::constants::EIGHT_TORSION { + let r = torsion.compress().to_bytes(); + let mut signature = [0u8; 64]; + signature[..32].copy_from_slice(&r); + let expected = if r == public_key { + Ok(()) + } else { + Err(Ed25519VerifyError::SignatureMismatch) + }; + assert_eq!( + verifier.verify_signature(&signature, &public_key, b"cofactorless torsion"), + expected + ); + } + } } diff --git a/ed25519-verify/src/verifier.rs b/ed25519-verify/src/verifier.rs index c2e0bd1..a9ae645 100644 --- a/ed25519-verify/src/verifier.rs +++ b/ed25519-verify/src/verifier.rs @@ -5,7 +5,7 @@ use { PUBKEY_SERIALIZED_SIZE, SIGNATURE_SERIALIZED_SIZE, }, error::Ed25519VerifyError, - points::{compute_challenge_into, is_small_order, multiply_by_8}, + points::{compute_challenge_into, is_small_order, is_small_order_canonical}, scalar, VerificationCriteria, }, solana_curve25519::{ @@ -126,11 +126,9 @@ impl Ed25519Verifier { if !self.criteria.cofactored { return Err(Ed25519VerifyError::SignatureMismatch); } - // `difference` came from `subtract_edwards`, so `None` should be - // unreachable; `InvalidEncoding` is defensive. - if multiply_by_8(&difference).ok_or(Ed25519VerifyError::InvalidEncoding)? - != EDWARDS_IDENTITY_COMPRESSED - { + // Subtraction produces a valid, canonical encoding. + // Its cofactor multiple is identity exactly when it is a torsion point. + if !is_small_order_canonical(&difference) { return Err(Ed25519VerifyError::SignatureMismatch); } diff --git a/program/tests/mollusk.rs b/program/tests/mollusk.rs index 0bbba3b..78520f1 100644 --- a/program/tests/mollusk.rs +++ b/program/tests/mollusk.rs @@ -319,3 +319,51 @@ fn verifies_signature_corpus_on_sbf_and_reports_compute_units() { min_CUs={min_cus}, max_CUs={max_cus}" ); } + +#[test] +fn accepts_zip215_torsion_encodings_on_sbf_and_reports_compute_units() { + let Some((mollusk, program_id)) = make_mollusk() else { + return; + }; + // Eight canonical torsion encodings, followed by six non-canonical aliases. + let encodings = [ + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000080", + "0100000000000000000000000000000000000000000000000000000000000000", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", + "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85", + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa", + "0100000000000000000000000000000000000000000000000000000000000080", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + ]; + + for (case, encoded) in encodings.iter().enumerate() { + let mut signature = [0u8; SIGNATURE_SERIALIZED_SIZE]; + for (i, byte) in signature[..32].iter_mut().enumerate() { + *byte = u8::from_str_radix(&encoded[2 * i..2 * i + 2], 16).unwrap(); + } + // A = identity and S = 0 leave -R as the verification difference. + let ix = verify( + &program_id, + &EDWARDS_IDENTITY_COMPRESSED, + &signature, + b"zip215 torsion fallback", + ); + let result = mollusk.process_instruction(&ix, &[]); + assert!( + result.program_result.is_ok(), + "torsion case={case} failed: {:?}", + result.program_result + ); + println!( + "ed25519 torsion: case={case}, CUs={}", + result.compute_units_consumed + ); + } +} From 43eaa9f0b18ad7c4736d9ff90071be41b5361280 Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Tue, 8 Sep 2026 23:58:13 +0900 Subject: [PATCH 14/18] Specialize Ed25519 MSM for two terms --- Cargo.lock | 1 + ed25519-verify/Cargo.toml | 9 ++++++- ed25519-verify/src/points.rs | 49 +++++++++++++++++++++++++++++++++- ed25519-verify/src/verifier.rs | 9 ++++--- program/tests/mollusk.rs | 40 +++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2388a33..7d3169f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2234,6 +2234,7 @@ dependencies = [ "ed25519-dalek", "solana-address 2.7.0", "solana-curve25519", + "solana-define-syscall 5.1.0", "solana-ed25519-verify", "solana-instruction", "solana-sha512-hasher", diff --git a/ed25519-verify/Cargo.toml b/ed25519-verify/Cargo.toml index 8107c81..0da6b95 100644 --- a/ed25519-verify/Cargo.toml +++ b/ed25519-verify/Cargo.toml @@ -12,7 +12,11 @@ default = ["verify", "instruction"] instruction = ["dep:solana-instruction", "dep:solana-address"] # Enables the in-program Ed25519 signature verifier (`Ed25519Verifier`) and # the curve/hash syscalls it depends on. -verify = ["dep:solana-curve25519", "dep:solana-sha512-hasher"] +verify = [ + "dep:solana-curve25519", + "dep:solana-sha512-hasher", + "dep:solana-define-syscall", +] [dependencies] solana-curve25519 = { workspace = true, optional = true } @@ -26,5 +30,8 @@ ed25519-dalek = { workspace = true } solana-address = { workspace = true, features = ["atomic"] } solana-ed25519-verify = { path = ".", features = ["instruction"] } +[target.'cfg(target_os = "solana")'.dependencies] +solana-define-syscall = { version = "=5.1.0", optional = true } + [lints] workspace = true diff --git a/ed25519-verify/src/points.rs b/ed25519-verify/src/points.rs index 3bffbac..8017da0 100644 --- a/ed25519-verify/src/points.rs +++ b/ed25519-verify/src/points.rs @@ -5,7 +5,10 @@ use { crate::{constants::EDWARDS_IDENTITY_COMPRESSED, error::Ed25519VerifyError, scalar}, - solana_curve25519::edwards::{add_edwards, PodEdwardsPoint}, + solana_curve25519::{ + edwards::{add_edwards, PodEdwardsPoint}, + scalar::PodScalar, + }, }; /// Returns `Ok(true)` if `point` decompresses to a small-order (torsion) point. @@ -85,6 +88,50 @@ pub(crate) fn is_small_order_canonical(point: &PodEdwardsPoint) -> bool { || y == ORDER_EIGHT_Y1 } +/// Computes an Edwards MSM with exactly two scalar-point pairs. +#[inline(always)] +pub(crate) fn multiscalar_multiply_edwards_2( + scalars: &[PodScalar; 2], + points: &[PodEdwardsPoint; 2], +) -> Option { + #[cfg(not(target_os = "solana"))] + { + solana_curve25519::edwards::multiscalar_multiply_edwards(scalars, points) + } + + #[cfg(target_os = "solana")] + { + use { + core::mem::MaybeUninit, solana_define_syscall::definitions::sol_curve_multiscalar_mul, + }; + + // Edwards25519 selector in Solana's curve syscall ABI. + const CURVE25519_EDWARDS: u64 = 0; + + let mut output = MaybeUninit::::uninit(); + + // SAFETY: Both input arrays contain exactly two contiguous POD + // encodings. The output has space for one encoded point and does + // not overlap either input. The syscall validates the encodings. + let status = unsafe { + sol_curve_multiscalar_mul( + CURVE25519_EDWARDS, + scalars.as_ptr().cast::(), + points.as_ptr().cast::(), + 2, + output.as_mut_ptr().cast::(), + ) + }; + + if status != 0 { + return None; + } + + // SAFETY: A successful syscall writes the complete output point. + Some(unsafe { output.assume_init() }) + } +} + #[cfg(test)] mod tests { use {super::*, crate::constants::PUBKEY_SERIALIZED_SIZE, ed25519_dalek::SigningKey}; diff --git a/ed25519-verify/src/verifier.rs b/ed25519-verify/src/verifier.rs index a9ae645..0d231fa 100644 --- a/ed25519-verify/src/verifier.rs +++ b/ed25519-verify/src/verifier.rs @@ -5,11 +5,14 @@ use { PUBKEY_SERIALIZED_SIZE, SIGNATURE_SERIALIZED_SIZE, }, error::Ed25519VerifyError, - points::{compute_challenge_into, is_small_order, is_small_order_canonical}, + points::{ + compute_challenge_into, is_small_order, is_small_order_canonical, + multiscalar_multiply_edwards_2, + }, scalar, VerificationCriteria, }, solana_curve25519::{ - edwards::{multiscalar_multiply_edwards, subtract_edwards, PodEdwardsPoint}, + edwards::{subtract_edwards, PodEdwardsPoint}, scalar::PodScalar, }, }; @@ -92,7 +95,7 @@ impl Ed25519Verifier { // `S*(-B) + H*A` is `-(S*B - H*A)`, the negation of the value the // verification equation compares against `R`. - let neg_lhs = multiscalar_multiply_edwards( + let neg_lhs = multiscalar_multiply_edwards_2( &scalars, &[ED25519_BASEPOINT_NEGATED_COMPRESSED, public_key_point], ) diff --git a/program/tests/mollusk.rs b/program/tests/mollusk.rs index 78520f1..aa5f9d1 100644 --- a/program/tests/mollusk.rs +++ b/program/tests/mollusk.rs @@ -367,3 +367,43 @@ fn accepts_zip215_torsion_encodings_on_sbf_and_reports_compute_units() { ); } } + +#[test] +fn rejects_invalid_msm_inputs_on_sbf() { + use {mollusk_svm::result::ProgramResult, pinocchio::error::ProgramError}; + + let Some((mollusk, program_id)) = make_mollusk() else { + return; + }; + + const ORDER: [u8; 32] = [ + 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, + 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x10, + ]; + + let mut signature = [0u8; SIGNATURE_SERIALIZED_SIZE]; + signature[..32].copy_from_slice(&EDWARDS_IDENTITY_COMPRESSED); + + // With A = R = identity, reducing S = L to zero would wrongly accept. + let mut noncanonical_s = signature; + noncanonical_s[32..].copy_from_slice(&ORDER); + + // Compressed y = 2 does not decompress to an Edwards point. + let mut invalid_public_key = [0u8; PUBKEY_SERIALIZED_SIZE]; + invalid_public_key[0] = 2; + + for (case, public_key, signature) in [ + ("S equals L", EDWARDS_IDENTITY_COMPRESSED, noncanonical_s), + ("invalid A", invalid_public_key, signature), + ] { + let ix = verify(&program_id, &public_key, &signature, b"invalid MSM inputs"); + let result = mollusk.process_instruction(&ix, &[]); + + assert_eq!( + result.program_result, + ProgramResult::Failure(ProgramError::InvalidInstructionData), + "{case}" + ); + } +} From 8571440be6ba30a837658589f8d6a1dbeb761d9d Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Wed, 9 Sep 2026 00:22:31 +0900 Subject: [PATCH 15/18] Use torsion lookup for strict small-order checks --- ed25519-verify/src/config.rs | 8 ++--- ed25519-verify/src/points.rs | 67 +++++++++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/ed25519-verify/src/config.rs b/ed25519-verify/src/config.rs index c278bf4..b63a755 100644 --- a/ed25519-verify/src/config.rs +++ b/ed25519-verify/src/config.rs @@ -55,13 +55,13 @@ pub struct VerificationCriteria { pub require_canonical_r: bool, /// Reject public keys that lie in the small-order (torsion) subgroup. /// - /// Costs a multiplication by the cofactor 8 (three `sol_curve_group_op` - /// additions) when enabled. + /// Costs one point addition to validate and canonicalize the encoding, + /// followed by a torsion lookup, when enabled. pub reject_small_order_a: bool, /// Reject signature `R` values that lie in the small-order subgroup. /// - /// Costs a multiplication by the cofactor 8 (three `sol_curve_group_op` - /// additions) when enabled. + /// Costs one point addition to validate and canonicalize the encoding, + /// followed by a torsion lookup, when enabled. pub reject_small_order_r: bool, } diff --git a/ed25519-verify/src/points.rs b/ed25519-verify/src/points.rs index 8017da0..81028bb 100644 --- a/ed25519-verify/src/points.rs +++ b/ed25519-verify/src/points.rs @@ -13,15 +13,15 @@ use { /// Returns `Ok(true)` if `point` decompresses to a small-order (torsion) point. /// -/// A point has order dividing the cofactor 8 exactly when `[8]P` is the -/// identity. This decompresses `point` (accepting non-canonical encodings, which -/// reduce modulo `p`). An encoding that does not decompress returns -/// `Err(InvalidEncoding)` so the caller can reject it immediately, rather than -/// treating it as non-small-order and paying for the subsequent verification -/// syscalls only to fail there. +/// Adding the identity validates and canonicalizes the encoding, including +/// non-canonical encodings accepted by decompression. The resulting point can +/// then be checked against the canonical torsion encodings. +/// +/// An encoding that does not decompress returns `Err(InvalidEncoding)`. pub(crate) fn is_small_order(point: &PodEdwardsPoint) -> Result { - let product = multiply_by_8(point).ok_or(Ed25519VerifyError::InvalidEncoding)?; - Ok(product == EDWARDS_IDENTITY_COMPRESSED) + let canonical = add_edwards(point, &EDWARDS_IDENTITY_COMPRESSED) + .ok_or(Ed25519VerifyError::InvalidEncoding)?; + Ok(is_small_order_canonical(&canonical)) } /// Multiplies `point` by the cofactor 8 via three point doublings. @@ -29,6 +29,7 @@ pub(crate) fn is_small_order(point: &PodEdwardsPoint) -> Result Option { let double = add_edwards(point, point)?; let quadruple = add_edwards(&double, &double)?; @@ -267,4 +268,54 @@ mod tests { ); } } + + #[test] + fn is_small_order_matches_dalek_on_canonical_and_noncanonical_inputs() { + use curve25519_dalek::{ + constants::{ED25519_BASEPOINT_POINT, EIGHT_TORSION}, + edwards::CompressedEdwardsY, + scalar::Scalar, + }; + + let check = |encoding: [u8; 32]| { + let expected = CompressedEdwardsY(encoding) + .decompress() + .map(|point| point.is_small_order()) + .ok_or(Ed25519VerifyError::InvalidEncoding); + + assert_eq!( + is_small_order(&PodEdwardsPoint(encoding)), + expected, + "encoding={encoding:02x?}" + ); + }; + + for i in 0..16u64 { + let prime = ED25519_BASEPOINT_POINT * Scalar::from(i); + for torsion in EIGHT_TORSION { + check((prime + torsion).compress().to_bytes()); + } + } + + // Every representable non-canonical y = p + n, for both sign bits, + // together with its reduced encoding. Includes invalid encodings. + for y in 0..19u8 { + for sign in [0u8, 0x80] { + let mut canonical = [0u8; 32]; + canonical[0] = y; + canonical[31] = sign; + check(canonical); + + let mut alias = [0xffu8; 32]; + alias[0] = 0xed + y; + alias[31] = 0x7f | sign; + check(alias); + } + } + + // The order-two point with the sign bit set despite x = 0. + let mut negative_zero = [0xffu8; 32]; + negative_zero[0] = 0xec; + check(negative_zero); + } } From d52b9da1e5a2de94c1ca8a217bf2c0fecffb281b Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Wed, 9 Sep 2026 09:25:25 +0900 Subject: [PATCH 16/18] resolve CI --- .config/spellcheck.dic | 2 ++ Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 1 + ed25519-verify/Cargo.toml | 2 +- ed25519-verify/src/config.rs | 8 ++++---- ed25519-verify/src/points.rs | 10 +++++----- ed25519-verify/src/scalar.rs | 10 +++++----- 7 files changed, 30 insertions(+), 27 deletions(-) diff --git a/.config/spellcheck.dic b/.config/spellcheck.dic index 36736b2..19f65bf 100644 --- a/.config/spellcheck.dic +++ b/.config/spellcheck.dic @@ -33,3 +33,5 @@ syscall syscalls verifier versa +MSM +radix diff --git a/Cargo.lock b/Cargo.lock index 7d3169f..3e49e03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1577,7 +1577,7 @@ checksum = "a6cababcb62a2e739c7078ae16eda02789a6e3edd21bbdded864e85c38a7914d" dependencies = [ "solana-account-view", "solana-address 2.7.0", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "solana-instruction-view", "solana-program-error", ] @@ -2049,7 +2049,7 @@ dependencies = [ "serde_derive", "sha2-const-stable", "solana-atomic-u64", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "solana-program-error", "solana-sanitize", "solana-sha256-hasher", @@ -2109,7 +2109,7 @@ dependencies = [ "ark-ff 0.5.0", "ark-serialize 0.5.0", "bytemuck", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "thiserror 2.0.18", ] @@ -2188,7 +2188,7 @@ dependencies = [ "bytemuck", "bytemuck_derive", "curve25519-dalek", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "subtle", "thiserror 2.0.18", ] @@ -2207,9 +2207,9 @@ checksum = "57e5b1c0bc1d4a4d10c88a4100499d954c09d3fecfae4912c1a074dff68b1738" [[package]] name = "solana-define-syscall" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e14a4f604117f379840956a8fc8695e4c84f5b0ebed192f31f60d9b85d581d" +checksum = "bf8209ece2bd9f1450e672858ffc0e5c8c786ff6916d2a862b126dd0128f380f" [[package]] name = "solana-ed25519-program" @@ -2234,7 +2234,7 @@ dependencies = [ "ed25519-dalek", "solana-address 2.7.0", "solana-curve25519", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "solana-ed25519-verify", "solana-instruction", "solana-sha512-hasher", @@ -2323,7 +2323,7 @@ dependencies = [ "bincode", "serde", "serde_derive", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "solana-instruction-error", "solana-pubkey 4.1.0", ] @@ -2348,7 +2348,7 @@ checksum = "6ab7a27d0c4214b9f7389c3dd00b68c93093a67f1dcc5b7893aebe299bbcbb47" dependencies = [ "solana-account-view", "solana-address 2.7.0", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "solana-program-error", ] @@ -2457,7 +2457,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "726b7cbbc6be6f1c6f29146ac824343b9415133eee8cce156452ad1db93f8008" dependencies = [ - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", ] [[package]] @@ -2674,7 +2674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c5f18893d62e6c73117dcba48f8f5e3266d90e5ec3d0a0a90f9785adac36c1" dependencies = [ "k256", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "thiserror 2.0.18", ] @@ -2707,7 +2707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11e10e103ab5bd52af341e7bc2f4123a2f4e66bb7ba4e6ca40f1e00cd6314e02" dependencies = [ "sha2 0.10.9", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "solana-hash-512", ] diff --git a/Cargo.toml b/Cargo.toml index c276045..67eea36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ serde = { version = "1.0.228", default-features = false } serde_derive = "1.0.228" solana-account = "3.4.0" solana-curve25519 = "4.0.1" +solana-define-syscall = "5.2.0" solana-ed25519-verify = { path = "ed25519-verify", version = "0.1.0", default-features = false } solana-instruction = "3.3.0" solana-program-error = "3.0.1" diff --git a/ed25519-verify/Cargo.toml b/ed25519-verify/Cargo.toml index 0da6b95..f3fcb09 100644 --- a/ed25519-verify/Cargo.toml +++ b/ed25519-verify/Cargo.toml @@ -31,7 +31,7 @@ solana-address = { workspace = true, features = ["atomic"] } solana-ed25519-verify = { path = ".", features = ["instruction"] } [target.'cfg(target_os = "solana")'.dependencies] -solana-define-syscall = { version = "=5.1.0", optional = true } +solana-define-syscall = { workspace = true, optional = true } [lints] workspace = true diff --git a/ed25519-verify/src/config.rs b/ed25519-verify/src/config.rs index b63a755..0b5fed5 100644 --- a/ed25519-verify/src/config.rs +++ b/ed25519-verify/src/config.rs @@ -55,13 +55,13 @@ pub struct VerificationCriteria { pub require_canonical_r: bool, /// Reject public keys that lie in the small-order (torsion) subgroup. /// - /// Costs one point addition to validate and canonicalize the encoding, - /// followed by a torsion lookup, when enabled. + /// Costs one point addition to validate the input and produce a canonical + /// encoding, followed by a torsion lookup, when enabled. pub reject_small_order_a: bool, /// Reject signature `R` values that lie in the small-order subgroup. /// - /// Costs one point addition to validate and canonicalize the encoding, - /// followed by a torsion lookup, when enabled. + /// Costs one point addition to validate the input and produce a canonical + /// encoding, followed by a torsion lookup, when enabled. pub reject_small_order_r: bool, } diff --git a/ed25519-verify/src/points.rs b/ed25519-verify/src/points.rs index 81028bb..b78c1fc 100644 --- a/ed25519-verify/src/points.rs +++ b/ed25519-verify/src/points.rs @@ -13,9 +13,9 @@ use { /// Returns `Ok(true)` if `point` decompresses to a small-order (torsion) point. /// -/// Adding the identity validates and canonicalizes the encoding, including -/// non-canonical encodings accepted by decompression. The resulting point can -/// then be checked against the canonical torsion encodings. +/// Adding the identity validates the input and produces a canonical encoding. +/// This accepts non-canonical encodings supported by decompression. The result +/// can then be checked against the canonical torsion encodings. /// /// An encoding that does not decompress returns `Err(InvalidEncoding)`. pub(crate) fn is_small_order(point: &PodEdwardsPoint) -> Result { @@ -60,7 +60,7 @@ pub(crate) fn compute_challenge( /// Tests torsion membership for a valid, canonical curve encoding. /// -/// Call this on points produced by curve operations, not unvalidated input. +/// Use only with canonical encodings returned by successful curve operations. #[inline(never)] pub(crate) fn is_small_order_canonical(point: &PodEdwardsPoint) -> bool { const ORDER_TWO_Y: [u8; 32] = { @@ -234,7 +234,7 @@ mod tests { for i in 0..16u64 { let prime = ED25519_BASEPOINT_POINT * Scalar::from(i); for torsion in &EIGHT_TORSION { - let point = &prime + torsion; + let point = prime + torsion; let encoded = PodEdwardsPoint(point.compress().to_bytes()); assert_eq!( is_small_order_canonical(&encoded), diff --git a/ed25519-verify/src/scalar.rs b/ed25519-verify/src/scalar.rs index 7b71c73..55cccc8 100644 --- a/ed25519-verify/src/scalar.rs +++ b/ed25519-verify/src/scalar.rs @@ -18,9 +18,9 @@ pub(crate) fn is_canonical_point_encoding(encoding: &[u8; 32]) -> bool { /// Reduces a 64-byte little-endian integer modulo the Ed25519 group order. /// -/// Uses radix-2^21 limbs and the relation 2^252 = -c (mod L), where -/// L = 2^252 + c. After folding, -L < r < L; adding L when r is negative -/// produces the canonical scalar. +/// Uses radix `2^21` limbs and the relation `2^252 = -c (mod L)`, where +/// `L = 2^252 + c`. After folding, `-L < r < L`; adding `L` when `r` is +/// negative produces a canonical scalar. pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { #[inline(always)] fn fold(limbs: &mut [i64; 24], index: usize) { @@ -35,11 +35,11 @@ pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { } let mut limbs = [0i64; 24]; - for i in 0..23 { + for (i, limb) in limbs.iter_mut().enumerate().take(23) { let bit = i * 21; let byte = bit / 8; let word = u32::from_le_bytes(wide[byte..byte + 4].try_into().unwrap()); - limbs[i] = i64::from((word >> (bit % 8)) & 0x1f_ffff); + *limb = i64::from((word >> (bit % 8)) & 0x1f_ffff); } limbs[23] = i64::from(u32::from_le_bytes(wide[60..64].try_into().unwrap()) >> 3); From 31e04452731948ad1fa6c8dbaa1cf20462225e6e Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Wed, 9 Sep 2026 10:23:26 +0900 Subject: [PATCH 17/18] update README --- README.md | 98 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e32fbe7..f4eb0d3 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ succeeded. ## Syscalls used -| Syscall | SDK wrapper | -| --------------------------- | ------------------------------------------------------------- | -| `sol_sha512` | `solana_sha512_hasher::hashv` | -| `sol_curve_group_op` | `solana_curve25519::edwards::{add_edwards, subtract_edwards}` | -| `sol_curve_multiscalar_mul` | `solana_curve25519::edwards::multiscalar_multiply_edwards` | +| Syscall | Wrapper or entry point | +| --------------------------- | --------------------------------------------------------------------------------------------------- | +| `sol_sha512` | `solana_sha512_hasher::hashv` | +| `sol_curve_group_op` | `solana_curve25519::edwards::{add_edwards, subtract_edwards}` | +| `sol_curve_multiscalar_mul` | Fixed two-term wrapper using `solana_define_syscall::definitions::sol_curve_multiscalar_mul` on SBF | `sol_sha512` is not live on mainnet yet. The wrapper crate is published as `solana-sha512-hasher`, and a local/custom VM must enable the SHA-512 syscall @@ -78,19 +78,27 @@ non-canonical encodings, and small-order rejection (see Henry de Valence's [It's 255:19AM]). The `solana-ed25519-verify` crate exposes these as independent knobs via `VerificationCriteria`: -| Knob | Effect when enabled | Extra syscalls | -| ---------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| `cofactored` | Use `[8](S·B − H·A − R) == identity` instead of the cofactorless `S·B − H·A − R == identity` | +3 `sol_curve_group_op` (1,419 CU), fallback only | -| `require_canonical_a` | Reject public keys whose `y`-coordinate is `≥ p` | none | -| `require_canonical_r` | Reject signature `R` whose `y`-coordinate is `≥ p` | none | -| `reject_small_order_a` | Reject small-order (torsion) public keys | +3 `sol_curve_group_op` (1,419 CU) | -| `reject_small_order_r` | Reject small-order signature `R` values | +3 `sol_curve_group_op` (1,419 CU) | - -The cofactor multiplication is only reached when a signature fails the exact -comparison first, so it costs nothing on the path a valid signature follows. - -Canonical `S` (`S < L`) has no knob. Every profile worth targeting requires it — -accepting `S ≥ L` reintroduces signature malleability — and +| Knob | Effect when enabled | Extra curve syscalls | +| ---------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| `cofactored` | Use `[8](S·B − H·A − R) == identity` instead of the cofactorless `S·B − H·A − R == identity` | none; torsion lookup on the fallback difference | +| `require_canonical_a` | Reject public keys whose `y`-coordinate is `≥ p` | none | +| `require_canonical_r` | Reject signature `R` whose `y`-coordinate is `≥ p` | none | +| `reject_small_order_a` | Reject small-order (torsion) public keys | +1 addition (473 CU), plus a torsion lookup | +| `reject_small_order_r` | Reject small-order signature `R` values | +1 addition (473 CU), plus a torsion lookup | + +The verifier first compares the computed point encoding with `R`. If they differ, +it subtracts `R` and checks the resulting point. The cofactored profile tests this +canonical difference against the torsion encodings, replacing three doublings +with a lookup. This lookup is skipped when the initial comparison succeeds. + +Each small-order input check adds the identity to validate the input and produce +a canonical encoding before the lookup. This preserves support for valid +non-canonical encodings and rejects inputs that do not decompress. The 473 CU +figure above is the addition syscall charge in the benchmark runtime; the total +check also includes the surrounding SBF instructions. + +Canonical `S` (`S < L`) has no knob. Every profile worth targeting requires it — +accepting `S ≥ L` reintroduces signature malleability and `sol_curve_multiscalar_mul` enforces it regardless, converting scalars through `Scalar::from_canonical_bytes` and rejecting out-of-range values before any group operation runs. @@ -208,3 +216,57 @@ unless `SBF_OUT_DIR` is set. Because published Mollusk/Agave crates do not yet register `sol_sha512`, `program/tests/mollusk.rs` installs a local SHA-512 syscall shim before loading the SBF program. A production/localnet VM must register the real `sol_sha512` syscall instead. + +## Compute units + +The measurements below were collected on September 8, 2026, using an SBF v2 +release build, Mollusk `0.13.1`, and the metered SHA-512 test syscall shim. They +include execution of the program wrapper and the syscall charges in that +harness. These are compute-unit measurements, not host execution times. + +The signature corpus contains 32 cases: signing-key seeds `7`, `42`, `99`, and +`201`, each tested with the eight message lengths below. For each message length, +all four seeds consumed the same number of compute units in the final measured +build. + +| Message bytes | Default ZIP-215 CU | Strict CU | +| ----------------: | -----------------: | -----------: | +| 0 | 3,847 | 4,980 | +| 1 | 3,847 | 4,980 | +| 38 | 3,856 | 4,989 | +| 47 | 3,860 | 4,993 | +| 48 | 3,861 | 4,994 | +| 49 | 3,861 | 4,994 | +| 128 | 3,901 | 5,034 | +| 1,024 | 4,349 | 5,482 | +| **32-case total** | **125,528** | **161,784** | +| **Mean per case** | **3,922.75** | **5,055.75** | + +The separate 38-byte signature fixture also consumes **3,856 CU** with ZIP-215 +and **4,989 CU** with `VerificationCriteria::dalek_verify_strict()`. The strict +column was measured using a separate build of the same program wrapper with +that preset selected. The shipped program continues to use ZIP-215. + +Other measured paths in the default ZIP-215 build: + +| Test case | CU | +| ------------------------------------------------ | ---------------------------------: | +| Accepted small-order public-key fixture | 4,388 | +| Accepted torsion encodings, 14 cases | 3,848-4,400 per case; 60,898 total | +| Tampered message or public key | 4,403 each | +| Non-canonical `S` or invalid public-key encoding | 3,836 each | +| Unexpected accounts | 18 | +| Instruction shorter than 96 bytes | 21 | + +To reproduce the default program measurements: + +```sh +cargo build-sbf --arch v2 --manifest-path program/Cargo.toml \ + --sbf-out-dir "$PWD/target/deploy" +SBF_OUT_DIR="$PWD/target/deploy" cargo test --locked \ + -p solana-ed25519-program --test mollusk \ + -- --nocapture --test-threads=1 +``` + +Changing the SBF toolchain, dependencies, or syscall cost model can change these +results. Compare builds using the same corpus and metered syscall shim. From 46268ee6396eb13a255c00988292076a848712e9 Mon Sep 17 00:00:00 2001 From: samkim-crypto Date: Thu, 17 Sep 2026 19:15:59 -0400 Subject: [PATCH 18/18] address wording comments --- README.md | 4 ++-- ed25519-verify/src/verifier.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f4eb0d3..a4a60a9 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,8 @@ non-canonical encodings and rejects inputs that do not decompress. The 473 CU figure above is the addition syscall charge in the benchmark runtime; the total check also includes the surrounding SBF instructions. -Canonical `S` (`S < L`) has no knob. Every profile worth targeting requires it — -accepting `S ≥ L` reintroduces signature malleability and +Canonical `S` (`S < L`) has no knob. Every profile worth targeting requires it +because accepting `S ≥ L` reintroduces signature malleability. The syscall `sol_curve_multiscalar_mul` enforces it regardless, converting scalars through `Scalar::from_canonical_bytes` and rejecting out-of-range values before any group operation runs. diff --git a/ed25519-verify/src/verifier.rs b/ed25519-verify/src/verifier.rs index 0d231fa..cde0c8d 100644 --- a/ed25519-verify/src/verifier.rs +++ b/ed25519-verify/src/verifier.rs @@ -118,8 +118,8 @@ impl Ed25519Verifier { let difference = subtract_edwards(&lhs, &r_point).ok_or(Ed25519VerifyError::InvalidEncoding)?; - // Exact identity satisfies both equations, so accept before paying for - // the cofactor multiplication. + // Exact identity satisfies both equations, so accept before performing + // the torsion lookup. if difference == EDWARDS_IDENTITY_COMPRESSED { return Ok(()); }