Skip to main content

binius_circuits/secp256k1/
endosplit.rs

1// Copyright 2025 Irreducible Inc.
2//! Secp256k1 endomorphism split
3//!
4//! The curve has an endomorphism `λ (x, y) = (βx, y)` where `λ³=1 (mod n)`
5//! and `β³=1 (mod p)` (`n` being the scalar field modulus and `p` coordinate field one).
6//!
7//! For a 256-bit scalar `k` it is possible to split it into `k1` and `k2` such that
8//! `k1 + λ k2 = k (mod n)` and both `k1` and `k2` are no farther than `2^128` from zero.
9//!
10//! The `k` scalar is represented by four 64-bit limbs in little endian order. The return value is
11//! quadruple of `(k1_neg, k2_neg, k1_abs, k2_abs)` where `k1_neg` and `k2_neg` are MSB-bools
12//! indicating whether `k1_abs` or `k2_abs`, respectively, should be negated. `k1_abs` and `k2_abs`
13//! are at most 128 bits and are represented with two 64-bit limbs. When `k` cannot be represented
14//! in this way (any valid scalar can, so it has to be modulus or above) both  `k1_abs` and `k2_abs`
15//! are assigned zero values.
16//!
17//! This is a hint - a deterministic computation that happens only on the prover side.
18//! The result should be additionally constrained by using bignum circuits to check that
19//! `k1 + λ k2 = k (mod n)`.
20
21use binius_core::Word;
22use binius_frontend::{CircuitBuilder, Wire, hints::Hint};
23use hex_literal::hex;
24use num_bigint::BigUint;
25
26use crate::bignum::num_biguint_from_u64_limbs;
27
28pub struct Secp256k1EndosplitHint {
29	minus_b1: BigUint,
30	minus_b2: BigUint,
31	g1: BigUint,
32	g2: BigUint,
33	endomorphism_lambda: BigUint,
34	scalar_modulus: BigUint,
35	scalar_modulus_half: BigUint,
36	k1_tight_bound: BigUint,
37	k2_tight_bound: BigUint,
38}
39
40impl Secp256k1EndosplitHint {
41	pub fn new() -> Self {
42		let [
43			minus_b1,
44			minus_b2,
45			g1,
46			g2,
47			endomorphism_lambda,
48			scalar_modulus,
49			k1_tight_bound,
50			k2_tight_bound,
51		] = [
52			hex!("e4437ed6010e88286f547fa90abfe4c3").as_slice(),
53			hex!("fffffffffffffffffffffffffffffffe8a280ac50774346dd765cda83db1562c").as_slice(),
54			hex!("3086d221a7d46bcde86c90e49284eb153daa8a1471e8ca7fe893209a45dbb031").as_slice(),
55			hex!("e4437ed6010e88286f547fa90abfe4c4221208ac9df506c61571b4ae8ac47f71").as_slice(),
56			hex!("5363ad4cc05c30e0a5261c028812645a122e22ea20816678df02967c1b23bd72").as_slice(),
57			hex!("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141").as_slice(),
58			hex!("a2a8918ca85bafe22016d0b917e4dd77").as_slice(),
59			hex!("8a65287bd47179fb2be08846cea267ed").as_slice(),
60		]
61		.map(num_bigint::BigUint::from_bytes_be);
62
63		let scalar_modulus_half = &scalar_modulus >> 1;
64
65		Self {
66			minus_b1,
67			minus_b2,
68			g1,
69			g2,
70			endomorphism_lambda,
71			scalar_modulus,
72			scalar_modulus_half,
73			k1_tight_bound,
74			k2_tight_bound,
75		}
76	}
77
78	/// Secp256k1 endomorphism split.
79	///
80	/// The curve has an endomorphism `λ (x, y) = (βx, y)` where `λ³=1 (mod n)`
81	/// and `β³=1 (mod p)` (`n` being the scalar field modulus and `p` coordinate field one).
82	///
83	/// For a 256-bit scalar `k` it is possible to split it into `k1` and `k2` such that
84	/// `k1 + λ k2 = k (mod n)` and both `k1` and `k2` are no farther than `2^128` from zero.
85	///
86	/// The `k` scalar is represented by four 64-bit limbs in little endian order. The return value
87	/// is quadruple of `(k1_neg, k2_neg, k1_abs, k2_abs)` where `k1_neg` and `k2_neg` are
88	/// MSB-bools indicating whether `k1_abs` or `k2_abs`, respectively, should be negated.
89	/// `k1_abs` and `k2_abs` are at most 128 bits and are represented with two 64-bit limbs.
90	/// When `k` cannot be represented in this way (any valid scalar can, so it has to be modulus
91	/// or above), both `k1_abs` and `k2_abs` are assigned zero values.
92	///
93	/// This is a hint - a deterministic computation that happens only on the prover side.
94	/// The result should be additionally constrained by using bignum circuits to check that
95	/// `k1 + λ k2 = k (mod n)`.
96	pub fn call(builder: &CircuitBuilder, k: &[Wire]) -> (Wire, Wire, [Wire; 2], [Wire; 2]) {
97		assert_eq!(k.len(), 4);
98		let out = builder.call_hint(Self::new(), &[], k);
99		let [k1_neg, k2_neg, k1_abs0, k1_abs1, k2_abs0, k2_abs1] = out.as_slice() else {
100			panic!("Secp256k1EndosplitHint must return 6 wires");
101		};
102		(*k1_neg, *k2_neg, [*k1_abs0, *k1_abs1], [*k2_abs0, *k2_abs1])
103	}
104
105	fn scalar_abs(&self, scalar: BigUint) -> (Word, BigUint) {
106		if scalar > self.scalar_modulus_half {
107			(Word::ALL_ONE, &self.scalar_modulus - scalar)
108		} else {
109			(Word::ZERO, scalar)
110		}
111	}
112}
113
114impl Default for Secp256k1EndosplitHint {
115	fn default() -> Self {
116		Self::new()
117	}
118}
119
120impl Hint for Secp256k1EndosplitHint {
121	const NAME: &'static str = "binius.secp256k1_endosplit";
122
123	fn shape(&self, dimensions: &[usize]) -> (usize, usize) {
124		assert!(dimensions.is_empty(), "Secp256k1EndosplitHint has constant shape");
125		(4, 6)
126	}
127
128	fn execute(&self, dimensions: &[usize], inputs: &[Word], outputs: &mut [Word]) {
129		assert!(dimensions.is_empty(), "Secp256k1EndosplitHint has constant shape");
130
131		assert_eq!(inputs.len(), 4);
132		assert_eq!(outputs.len(), 6);
133
134		let k =
135			num_biguint_from_u64_limbs(inputs.iter().map(|w| w.as_u64())) % &self.scalar_modulus;
136
137		// https://github.com/bitcoin-core/secp256k1/blob/master/src/scalar_impl.h#L92-L141
138		let c1 = div_pow2_round(&k * &self.g1, 384) * &self.minus_b1;
139		let c2 = div_pow2_round(&k * &self.g2, 384) * &self.minus_b2;
140
141		let k2 = (c1 + c2) % &self.scalar_modulus;
142		let k2_lambda = (&k2 * &self.endomorphism_lambda) % &self.scalar_modulus;
143		let k1 = (&self.scalar_modulus - k2_lambda + k) % &self.scalar_modulus;
144
145		// bring the magnitude of k1 & k2 below 2^128 by conditional negation
146		let (k1_neg, mut k1_abs) = self.scalar_abs(k1);
147		let (k2_neg, mut k2_abs) = self.scalar_abs(k2);
148
149		if k1_abs >= self.k1_tight_bound || k2_abs >= self.k2_tight_bound {
150			k1_abs = BigUint::ZERO;
151			k2_abs = BigUint::ZERO;
152		}
153
154		outputs.fill(Word::ZERO);
155
156		outputs[0] = k1_neg;
157		outputs[1] = k2_neg;
158
159		for (output, limb) in outputs[2..4].iter_mut().zip(k1_abs.iter_u64_digits()) {
160			*output = Word::from_u64(limb);
161		}
162
163		for (output, limb) in outputs[4..6].iter_mut().zip(k2_abs.iter_u64_digits()) {
164			*output = Word::from_u64(limb);
165		}
166	}
167}
168
169fn div_pow2_round(value: BigUint, shift: u64) -> BigUint {
170	let increment = if shift == 0 || !value.bit(shift - 1) {
171		BigUint::ZERO
172	} else {
173		BigUint::from(1usize)
174	};
175	(value >> shift) + increment
176}
177
178#[cfg(test)]
179mod tests {
180	use proptest::prelude::*;
181
182	use super::*;
183
184	proptest! {
185		#[test]
186		fn prop_secp256k1_endosplit(k in any::<[u64; 4]>()) {
187			let modulus = num_bigint::BigUint::from_bytes_be(
188				&hex_literal::hex!("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")
189			);
190			let lambda =  num_bigint::BigUint::from_bytes_be(
191				&hex_literal::hex!("5363ad4cc05c30e0a5261c028812645a122e22ea20816678df02967c1b23bd72")
192			);
193			let k_bignum = num_biguint_from_u64_limbs(k.iter());
194			prop_assume!(k_bignum < modulus);
195			prop_assume!(k_bignum > num_bigint::BigUint::ZERO);
196
197			let builder = CircuitBuilder::new();
198			let k = k.map(|limb| builder.add_constant_64(limb));
199			let (k1_neg, k2_neg, k1_abs, k2_abs) =
200				Secp256k1EndosplitHint::call(&builder, &k);
201
202			// A hint emits no constraint of its own, so pinning alone leaves these uncommitted.
203			// Promoting them to public outputs is what the test needs to read them back.
204			builder.mark_inout(k1_neg);
205			builder.mark_inout(k2_neg);
206			builder.mark_inout(k1_abs[0]);
207			builder.mark_inout(k1_abs[1]);
208			builder.mark_inout(k2_abs[0]);
209			builder.mark_inout(k2_abs[1]);
210
211			let circuit = builder.build();
212			let mut w = circuit.new_witness_filler();
213			circuit.populate_wire_witness(&mut w).unwrap();
214
215			let k1_abs_bignum = num_biguint_from_u64_limbs(k1_abs.iter().map(|&l| &w[l].0));
216			let k2_abs_bignum = num_biguint_from_u64_limbs(k2_abs.iter().map(|&l| &w[l].0));
217
218			assert!(k1_abs_bignum.bits() <= 128);
219			assert!(k2_abs_bignum.bits() <= 128);
220
221			let k1 = if w[k1_neg] != Word::ZERO {
222				&modulus - k1_abs_bignum
223			} else {
224				k1_abs_bignum
225			};
226
227			let k2 = if w[k2_neg] != Word::ZERO {
228				&modulus - k2_abs_bignum
229			} else {
230				k2_abs_bignum
231			};
232
233			assert_eq!((k1 + lambda * k2) % modulus, k_bignum);
234		}
235	}
236}