Skip to main content

binius_circuits/bitcoin/
p2pkh_signature.rs

1// Copyright 2026 The Binius Developers
2// Copyright 2025 Irreducible Inc.
3
4use binius_core::word::Word;
5use binius_frontend::{CircuitBuilder, Wire};
6
7use crate::{
8	bignum::BigUint,
9	bytes::swap_bytes_32,
10	ecdsa::scalar_mul::scalar_mul,
11	ripemd::ripemd160_fixed,
12	secp256k1::{Secp256k1, Secp256k1Affine},
13	sha256::sha256_fixed,
14	util::clear_high_bits,
15};
16
17/// Convert 20-byte address payload into five little-endian u32 words (circuit witness layout).
18pub const fn addr_bytes_to_le_words(addr: &[u8; 20]) -> [u32; 5] {
19	[
20		u32::from_le_bytes([addr[0], addr[1], addr[2], addr[3]]),
21		u32::from_le_bytes([addr[4], addr[5], addr[6], addr[7]]),
22		u32::from_le_bytes([addr[8], addr[9], addr[10], addr[11]]),
23		u32::from_le_bytes([addr[12], addr[13], addr[14], addr[15]]),
24		u32::from_le_bytes([addr[16], addr[17], addr[18], addr[19]]),
25	]
26}
27
28/// Builds a circuit that proves knowledge of a Bitcoin private key corresponding to a P2PKH
29/// address.
30///
31/// This circuit implements the complete Bitcoin P2PKH address derivation:
32/// Private Key → \[scalar_mul\] → Curve Point → \[compress\] → Compressed PubKey
33/// → \[sha256\] → SHA256 Digest → \[swap_bytes_32\] → LE Format → \[ripemd160\] → Address
34///
35/// # Arguments
36/// * `builder` - Circuit builder for constructing constraints
37/// * `private_key` - Private key as BigUint (4 limbs, 256 bits)
38/// * `expected_address` - Expected Bitcoin address as RIPEMD160 output (5 × 32-bit words)
39///
40/// # Circuit Flow
41/// 1. Multiply private key by secp256k1 generator point
42/// 2. Compress the resulting public key to 33-byte format
43/// 3. Compute SHA256 hash of compressed public key
44/// 4. Convert SHA256 output from big-endian to little-endian format
45/// 5. Compute RIPEMD160 hash of the SHA256 digest
46/// 6. Assert computed address equals expected address
47///
48/// # Panics
49/// * If private_key doesn't have exactly 4 limbs (256 bits)
50pub fn build_p2pkh_circuit(
51	builder: &CircuitBuilder,
52	private_key: &BigUint,
53	expected_address: [Wire; 5],
54) {
55	assert_eq!(private_key.limbs.len(), 4, "private_key must be exactly 4 limbs (256 bits)");
56
57	// Step 1: Scalar multiplication - private_key × generator → public key point
58	let curve = Secp256k1::new(builder);
59	let generator = Secp256k1Affine::generator(builder);
60
61	let public_key_point = scalar_mul(builder, &curve, private_key, generator);
62
63	// Step 2: Compress public key - (x, y) → 33-byte compressed format
64	let compressed_pubkey = compress_pubkey(builder, &public_key_point.x, &public_key_point.y);
65
66	// Step 3: SHA256 hash - 33 bytes → 32-byte digest
67	let sha256_digest = sha256_fixed(builder, &compressed_pubkey, 33);
68
69	// Step 4: Convert SHA256 output from BE to LE format for RIPEMD160
70	// sha256_fixed returns [Wire; 8] (8 × 32-bit words in big-endian)
71	// We need to swap bytes in each word to get little-endian format
72	let mut swapped_digest = Vec::with_capacity(8);
73	for &word in &sha256_digest {
74		swapped_digest.push(swap_bytes_32(builder, word));
75	}
76
77	// Step 5: RIPEMD160 hash - 32 bytes → 20-byte address
78	let computed_address = ripemd160_fixed(builder, &swapped_digest, 32);
79
80	// Step 6: Verify computed address matches expected address
81	for i in 0..5 {
82		builder.assert_eq(
83			format!("p2pkh_address[{}]", i),
84			computed_address[i],
85			expected_address[i],
86		);
87	}
88}
89
90/// Compresses a secp256k1 public key from uncompressed (x, y) format to compressed format.
91///
92/// Bitcoin uses compressed public keys which are 33 bytes instead of 65 bytes:
93/// - Uncompressed: \[0x04\] || x (32 bytes) || y (32 bytes) = 65 bytes
94/// - Compressed: \[0x02 or 0x03\] || x (32 bytes) = 33 bytes
95///
96/// The prefix byte indicates the parity of the y-coordinate:
97/// - 0x02 if y is even (LSB = 0)
98/// - 0x03 if y is odd (LSB = 1)
99///
100/// # Arguments
101/// * `builder` - Circuit builder for constructing constraints
102/// * `x` - x-coordinate of the public key (32 bytes, 4 limbs)
103/// * `y` - y-coordinate of the public key (32 bytes, 4 limbs)
104///
105/// # Returns
106/// * `Vec<Wire>` - Compressed public key as 33 bytes suitable for sha256_fixed input. Each wire
107///   contains 4 bytes (32-bit word) with high 32 bits zeroed.
108///
109/// # Panics
110/// * If x or y don't have exactly 4 limbs (256 bits)
111pub fn compress_pubkey(builder: &CircuitBuilder, x: &BigUint, y: &BigUint) -> Vec<Wire> {
112	assert_eq!(x.limbs.len(), 4, "x-coordinate must be exactly 4 limbs (256 bits)");
113	assert_eq!(y.limbs.len(), 4, "y-coordinate must be exactly 4 limbs (256 bits)");
114
115	let y_is_odd = builder.shl(y.limbs[0], 63);
116
117	// Create prefix: 0x02 if y is even, 0x03 if y is odd
118	let prefix_even = builder.add_constant(Word::from_u64(0x02 << 24));
119	let prefix_odd = builder.add_constant(Word::from_u64(0x03 << 24));
120	let prefix_byte = builder.select(y_is_odd, prefix_odd, prefix_even);
121
122	// We need to produce 9 words (33 bytes) for sha256_fixed
123	// Each word represents 4 bytes packed in big-endian format
124	let lower_32_bits = |limb| clear_high_bits(builder, limb, 32);
125	// Each packing merges a right-shift (bits below 24) with either the prefix byte (bits 24..32)
126	// or a left-shift-by-24 (bits 24 and up). The two operands occupy disjoint bit ranges, so the
127	// OR carries nothing and lowers to a free XOR instead of an AND.
128	vec![
129		builder.bxor(builder.shr(x.limbs[3], 40), prefix_byte),
130		lower_32_bits(builder.shr(x.limbs[3], 8)),
131		lower_32_bits(builder.bxor(builder.shl(x.limbs[3], 24), builder.shr(x.limbs[2], 40))),
132		lower_32_bits(builder.shr(x.limbs[2], 8)),
133		lower_32_bits(builder.bxor(builder.shl(x.limbs[2], 24), builder.shr(x.limbs[1], 40))),
134		lower_32_bits(builder.shr(x.limbs[1], 8)),
135		lower_32_bits(builder.bxor(builder.shl(x.limbs[1], 24), builder.shr(x.limbs[0], 40))),
136		lower_32_bits(builder.shr(x.limbs[0], 8)),
137		lower_32_bits(builder.shl(x.limbs[0], 24)),
138	]
139}
140
141#[cfg(test)]
142mod tests {
143	use binius_core::word::Word;
144	use bitcoin::{PublicKey, hashes::Hash};
145	use rand::prelude::*;
146
147	use super::*;
148
149	/// This is a reference function reused by tests and examples
150	/// Compute compressed SEC1 public key from a 32-byte big-endian private key.
151	/// Panics if the scalar is zero.
152	pub fn compressed_pubkey_from_be_sk(sk_be: [u8; 32]) -> [u8; 33] {
153		use k256::{ProjectivePoint, Scalar, elliptic_curve::sec1::ToSec1Point};
154
155		// Interpret big-endian bytes as scalar mod curve order via Horner's method
156		let mut s = Scalar::from(0u64);
157		let base = Scalar::from(256u64);
158		for &b in &sk_be {
159			s = s * base + Scalar::from(b as u64);
160		}
161		assert!(!bool::from(s.is_zero()), "private key must be non-zero");
162
163		let affine = ProjectivePoint::mul_by_generator(&s).to_affine();
164		let ep = affine.to_sec1_point(true);
165		let mut out = [0u8; 33];
166		out.copy_from_slice(ep.as_bytes());
167		out
168	}
169
170	/// Convenience to produce a random pair (privkey_be, compressed_pubkey)
171	pub fn gen_priv_pub_pair_from_rng(mut rng: impl Rng) -> ([u8; 32], [u8; 33]) {
172		let mut sk_be = [0u8; 32];
173		rng.fill_bytes(&mut sk_be);
174		let pk_comp = compressed_pubkey_from_be_sk(sk_be);
175		(sk_be, pk_comp)
176	}
177
178	// Utility: validate both compressed pubkey and full P2PKH circuit against a priv/pub pair
179	fn validate_priv_pub_pair(private_key_be: [u8; 32], compressed_pubkey: [u8; 33]) {
180		// 1) Check scalar_mul + compression matches expected compressed pubkey
181		assert_circuit_matches_priv_pub_pair(private_key_be, compressed_pubkey);
182
183		// 2) Check the full P2PKH circuit (SHA256 then RIPEMD160 on compressed pubkey)
184		let builder = CircuitBuilder::new();
185		let private_key = BigUint::new_witness(&builder, 4);
186		let expected_address: [Wire; 5] = std::array::from_fn(|_| builder.add_witness());
187
188		build_p2pkh_circuit(&builder, &private_key, expected_address);
189
190		let circuit = builder.build();
191		let mut w = circuit.new_witness_filler();
192
193		// Populate private key limbs (LE u64s)
194		let mut sk_le = [0u8; 32];
195		for i in 0..32 {
196			sk_le[i] = private_key_be[31 - i];
197		}
198		let limbs: [u64; 4] = [
199			u64::from_le_bytes(sk_le[0..8].try_into().unwrap()),
200			u64::from_le_bytes(sk_le[8..16].try_into().unwrap()),
201			u64::from_le_bytes(sk_le[16..24].try_into().unwrap()),
202			u64::from_le_bytes(sk_le[24..32].try_into().unwrap()),
203		];
204		private_key.populate_limbs(&mut w, &limbs);
205
206		// Compute expected P2PKH address using bitcoin crate and populate expected wires
207		let public_key =
208			PublicKey::from_slice(&compressed_pubkey).expect("Invalid compressed public key");
209		let pubkey_hash = public_key.pubkey_hash();
210		let addr_bytes: [u8; 20] = pubkey_hash.to_byte_array();
211		for i in 0..5 {
212			let start = i * 4;
213			let v = u32::from_le_bytes([
214				addr_bytes[start],
215				addr_bytes[start + 1],
216				addr_bytes[start + 2],
217				addr_bytes[start + 3],
218			]);
219			w[expected_address[i]] = Word::from_u64(v as u64);
220		}
221
222		circuit.populate_wire_witness(&mut w).unwrap();
223		circuit
224			.constraint_system()
225			.verify(&w.into_value_vec())
226			.unwrap();
227	}
228
229	// Utility: verify the circuit's scalar_mul + compression against a given private/public pair
230	// private_key_be: 32-byte big-endian private key
231	// expected_compressed_sec1: 33-byte SEC1 compressed public key (prefix + 32-byte x)
232	fn assert_circuit_matches_priv_pub_pair(
233		private_key_be: [u8; 32],
234		expected_compressed_sec1: [u8; 33],
235	) {
236		let builder = CircuitBuilder::new();
237
238		// Inputs and expected outputs
239		let private_key = BigUint::new_witness(&builder, 4);
240		let expected_words: Vec<Wire> = (0..9).map(|_| builder.add_witness()).collect();
241
242		// Compute pubkey in the circuit
243		let curve = Secp256k1::new(&builder);
244		let generator = Secp256k1Affine::generator(&builder);
245		let pub_point = scalar_mul(&builder, &curve, &private_key, generator);
246		let compressed = compress_pubkey(&builder, &pub_point.x, &pub_point.y);
247
248		for i in 0..9 {
249			builder.assert_eq(format!("compressed[{i}]"), compressed[i], expected_words[i]);
250		}
251
252		let circuit = builder.build();
253		let mut w = circuit.new_witness_filler();
254
255		// Populate private key limbs (circuit expects 4x u64 little-endian limbs)
256		let mut sk_le = [0u8; 32];
257		for i in 0..32 {
258			sk_le[i] = private_key_be[31 - i];
259		}
260		let limbs: [u64; 4] = [
261			u64::from_le_bytes(sk_le[0..8].try_into().unwrap()),
262			u64::from_le_bytes(sk_le[8..16].try_into().unwrap()),
263			u64::from_le_bytes(sk_le[16..24].try_into().unwrap()),
264			u64::from_le_bytes(sk_le[24..32].try_into().unwrap()),
265		];
266		private_key.populate_limbs(&mut w, &limbs);
267
268		// Pack expected compressed bytes into 9 big-endian u32 words (low 32 bits used)
269		let mut expected_word_values = [0u32; 9];
270		for i in 0..9 {
271			let start = i * 4;
272			let mut word = 0u32;
273			for j in 0..4 {
274				if start + j < 33 {
275					word |= (expected_compressed_sec1[start + j] as u32) << (24 - j * 8);
276				}
277			}
278			expected_word_values[i] = word;
279		}
280		for i in 0..9 {
281			w[expected_words[i]] = Word::from_u64(expected_word_values[i] as u64);
282		}
283
284		circuit.populate_wire_witness(&mut w).unwrap();
285		circuit
286			.constraint_system()
287			.verify(&w.into_value_vec())
288			.unwrap();
289	}
290
291	fn test_compress_helper(x_bytes: [u8; 32], y_bytes: [u8; 32], prefix: u8) {
292		let builder = CircuitBuilder::new();
293
294		// Convert byte arrays to BigUint limbs (little-endian)
295		let x = BigUint::new_witness(&builder, 4);
296		let y = BigUint::new_witness(&builder, 4);
297
298		// Expected compressed output wires for verification
299		let expected_words: Vec<Wire> = (0..9).map(|_| builder.add_witness()).collect();
300
301		// Call compress function
302		let compressed = compress_pubkey(&builder, &x, &y);
303
304		// Assert equality with expected result
305		for i in 0..9 {
306			builder.assert_eq(format!("compressed[{}]", i), compressed[i], expected_words[i]);
307		}
308
309		let circuit = builder.build();
310		let mut w = circuit.new_witness_filler();
311
312		// Populate x and y coordinates
313		let x_limbs: [u64; 4] = [
314			u64::from_le_bytes([
315				x_bytes[0], x_bytes[1], x_bytes[2], x_bytes[3], x_bytes[4], x_bytes[5], x_bytes[6],
316				x_bytes[7],
317			]),
318			u64::from_le_bytes([
319				x_bytes[8],
320				x_bytes[9],
321				x_bytes[10],
322				x_bytes[11],
323				x_bytes[12],
324				x_bytes[13],
325				x_bytes[14],
326				x_bytes[15],
327			]),
328			u64::from_le_bytes([
329				x_bytes[16],
330				x_bytes[17],
331				x_bytes[18],
332				x_bytes[19],
333				x_bytes[20],
334				x_bytes[21],
335				x_bytes[22],
336				x_bytes[23],
337			]),
338			u64::from_le_bytes([
339				x_bytes[24],
340				x_bytes[25],
341				x_bytes[26],
342				x_bytes[27],
343				x_bytes[28],
344				x_bytes[29],
345				x_bytes[30],
346				x_bytes[31],
347			]),
348		];
349
350		let y_limbs: [u64; 4] = [
351			u64::from_le_bytes([
352				y_bytes[0], y_bytes[1], y_bytes[2], y_bytes[3], y_bytes[4], y_bytes[5], y_bytes[6],
353				y_bytes[7],
354			]),
355			u64::from_le_bytes([
356				y_bytes[8],
357				y_bytes[9],
358				y_bytes[10],
359				y_bytes[11],
360				y_bytes[12],
361				y_bytes[13],
362				y_bytes[14],
363				y_bytes[15],
364			]),
365			u64::from_le_bytes([
366				y_bytes[16],
367				y_bytes[17],
368				y_bytes[18],
369				y_bytes[19],
370				y_bytes[20],
371				y_bytes[21],
372				y_bytes[22],
373				y_bytes[23],
374			]),
375			u64::from_le_bytes([
376				y_bytes[24],
377				y_bytes[25],
378				y_bytes[26],
379				y_bytes[27],
380				y_bytes[28],
381				y_bytes[29],
382				y_bytes[30],
383				y_bytes[31],
384			]),
385		];
386
387		x.populate_limbs(&mut w, &x_limbs);
388		y.populate_limbs(&mut w, &y_limbs);
389
390		// Create expected compressed public key format (embedded logic)
391		let mut expected_compressed = [0u8; 33];
392		expected_compressed[0] = prefix;
393		for i in 0..32 {
394			expected_compressed[1 + i] = x_bytes[31 - i];
395		}
396
397		// Pack expected compressed bytes into 32-bit words for comparison
398		let mut expected_word_values = [0u32; 9];
399		for i in 0..9 {
400			let word_start = i * 4;
401			if word_start < 33 {
402				let bytes_in_word = std::cmp::min(4, 33 - word_start);
403				let mut word = 0u32;
404				for j in 0..bytes_in_word {
405					word |= (expected_compressed[word_start + j] as u32) << (24 - j * 8);
406				}
407				expected_word_values[i] = word;
408			}
409		}
410
411		for i in 0..9 {
412			w[expected_words[i]] = Word::from_u64(expected_word_values[i] as u64);
413		}
414
415		circuit.populate_wire_witness(&mut w).unwrap();
416		circuit
417			.constraint_system()
418			.verify(&w.into_value_vec())
419			.unwrap();
420	}
421
422	#[test]
423	fn test_p2pkh_circuit_privkey_external() {
424		let (sk_be, pk_comp) = gen_priv_pub_pair_from_rng(StdRng::seed_from_u64(0));
425		validate_priv_pub_pair(sk_be, pk_comp);
426	}
427
428	#[test]
429	fn test_compress_simple() {
430		// Simple test with known values to debug byte ordering
431		let x_bytes = [
432			0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
433			0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C,
434			0x1D, 0x1E, 0x1F, 0x20,
435		];
436
437		let y_bytes = [
438			0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
439			0x00, // Even y (LSB = 0x02 which is even)
440			0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
441			0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
442		];
443
444		// Expected compressed: prefix 0x02 + x bytes in big-endian (reverse of LE storage)
445		test_compress_helper(x_bytes, y_bytes, 0x02);
446	}
447
448	#[test]
449	fn test_compress_odd_y() {
450		// Point with odd y coordinate
451		let x_bytes = [
452			0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
453			0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11,
454			0x11, 0x11, 0x11, 0x11,
455		];
456
457		let y_bytes = [
458			0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, // LSB is odd
459			0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33,
460			0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33,
461		];
462
463		// Expected compressed format: 0x03 prefix + x coordinate big-endian (reverse of LE storage)
464		test_compress_helper(x_bytes, y_bytes, 0x03);
465	}
466
467	#[test]
468	fn test_compress_even_y() {
469		// Point with even y coordinate
470		let x_bytes = [
471			0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA,
472			0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA,
473			0xAA, 0xAA, 0xAA, 0xAA,
474		];
475
476		let y_bytes = [
477			0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, // LSB is even
478			0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44,
479			0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44,
480		];
481
482		// Expected compressed format: 0x02 prefix + x coordinate big-endian (reverse of LE storage)
483		test_compress_helper(x_bytes, y_bytes, 0x02);
484	}
485}