secp256k1 ECDSA Cryptography Precompile
Native EVM precompile for secp256k1 ECDSA operations with optimized libsecp256k1 backend
Abstract
LP-3652 specifies a native EVM precompile for secp256k1 ECDSA cryptographic operations, providing high-performance signature verification, public key recovery, and batch operations. The precompile leverages the battle-tested libsecp256k1 library (Bitcoin Core's implementation) via CGO bindings, offering 10-100x performance improvements over Solidity-based implementations.
Motivation
Current Limitations
EVM ecrecover Precompile (0x01):
- Only supports public key recovery from signatures
- No direct signature verification
- No batch operations
- No point multiplication or addition
- Limited to recovery ID handling
Solidity-Based ECDSA:
- ~6,000-8,000 gas for signature verification
- No access to optimized assembly implementations
- Vulnerable to side-channel attacks in naive implementations
- Cannot leverage libsecp256k1 optimizations
Use Cases Requiring Native secp256k1
-
Threshold Signatures (FROST, CGGMP21)
- Schnorr signature aggregation
- Distributed key generation
- Partial signature verification
-
Bitcoin/Ethereum Compatibility
- Transaction signing
- Message verification
- BIP-340 Schnorr (Taproot)
-
Cross-Chain Bridges
- Multi-signature verification
- Light client proofs
- SPV verification
-
Zero-Knowledge Proofs
- Elliptic curve operations for ZK circuits
- Pedersen commitments
- Bulletproofs
Performance Benefits
| Operation | Solidity | Precompile | Improvement |
|---|---|---|---|
| ECDSA Verify | 8,000 gas | 3,000 gas | 2.7x |
| Public Key Recovery | 3,000 gas | 500 gas | 6x |
| Batch Verify (10 sigs) | 80,000 gas | 15,000 gas | 5.3x |
| Point Multiplication | 40,000 gas | 2,000 gas | 20x |
| Schnorr Verify | 12,000 gas | 2,500 gas | 4.8x |
Rationale
secp256k1 Curve Selection
secp256k1 is the most battle-tested curve for blockchain:
- Bitcoin Standard: Used by Bitcoin since 2009
- Ethereum Compatible: Default for Ethereum accounts
- Performance: Efficient arithmetic, well-optimized libraries
- Security: No known practical attacks after 15+ years
libsecp256k1 Library
Using Bitcoin Core's implementation provides:
- Audit Trail: Extensively reviewed by Bitcoin developers
- Constant-Time: All operations use constant-time algorithms
- Optimization: Hand-tuned assembly for multiple platforms
- Reliability: Zero known security vulnerabilities
Precompile Address Choice
Using 0x0312 (498+ in hex) for secp256k1:
- Lower than other crypto precompiles for historical reasons
- Grouping all secp256k1 operations
- Matches expected address pattern
Function Selector Design
Organized by operation category:
0x01-0x0F: ECDSA operations (verify, recover)0x10-0x1F: Point operations (add, mul, negate)- `0x20-0x2F': Schnorr operations (sign, verify, aggregate)
- `0x30-0x3F': Key operations (derive, validate)
Gas Cost Derivation
Gas based on computational complexity relative to ecrecover:
| Operation | Complexity | Ratio to ecrecover | Gas |
|---|---|---|---|
| ecdsaVerify | 1x (baseline) | ~1x | 3,000 |
| ecdsaRecover | 1.2x baseline | ~1x | 500 |
| batchVerify | n x 0.8x | ~0.8n | 15,000 (10) |
| ecMul | 3x baseline | ~3x | 2,000 |
| schnorrVerify | 0.8x ecdsa | ~0.8x | 2,500 |
Why Not Replace ecrecover?
- Backwards Compatibility: Existing contracts rely on ecrecover
- Different Interface: This precompile has richer functionality
- Cost Optimization: ecrecover is sufficient for simple recovery
BIP-340 Schnorr Support
Adding Schnorr provides:
- Signature Aggregation: Multiple signatures combine to one
- Privacy: MAST support for complex spending conditions
- Efficiency: Smaller signatures (64 bytes vs 65+)
- Future-Proofing: Foundation for Taproot-style applications
Specification
Precompile Address
| Address | Operation |
|---|---|
0x0312 | secp256k1 ECDSA Operations |
Function Selectors
| Selector | Function | Gas |
|---|---|---|
0x01 | ecdsaVerify(bytes32 hash, bytes sig, bytes pubkey) | 3,000 |
0x02 | ecdsaRecover(bytes32 hash, bytes sig) | 500 |
0x03 | ecdsaVerifyBatch(bytes32[] hashes, bytes[] sigs, bytes[] pubkeys) | 1,500 + 1,000/sig |
0x04 | schnorrVerify(bytes32 hash, bytes sig, bytes pubkey) | 2,500 |
0x05 | schnorrVerifyBatch(bytes32[] hashes, bytes[] sigs, bytes[] pubkeys) | 1,200 + 800/sig |
0x10 | pointMul(bytes point, bytes32 scalar) | 2,000 |
0x11 | pointAdd(bytes point1, bytes point2) | 500 |
0x12 | pointNeg(bytes point) | 100 |
0x13 | isOnCurve(bytes point) | 200 |
0x20 | pubkeyCreate(bytes32 privkey) | 1,500 |
0x21 | pubkeySerialize(bytes pubkey, bool compressed) | 100 |
0x22 | pubkeyParse(bytes pubkey) | 150 |
0x23 | pubkeyTweakAdd(bytes pubkey, bytes32 tweak) | 1,000 |
0x24 | pubkeyTweakMul(bytes pubkey, bytes32 tweak) | 1,500 |
0x25 | pubkeyCombine(bytes[] pubkeys) | 500 + 200/key |
Input/Output Encoding
ECDSA Signature Format
Standard (65 bytes): r (32) || s (32) || v (1)
Compact (64 bytes): r (32) || s (32) // v derived from recovery
Public Key Format
Compressed (33 bytes): prefix (1) || x (32)
Uncompressed (65 bytes): 0x04 || x (32) || y (32)
Schnorr Signature Format (BIP-340)
Schnorr (64 bytes): r (32) || s (32)
Solidity Interface
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface ISecp256k1 {
/// @notice Verify an ECDSA signature
/// @param hash The 32-byte message hash
/// @param signature The 64 or 65 byte signature (r, s, [v])
/// @param publicKey The 33 or 65 byte public key
/// @return valid True if signature is valid
function ecdsaVerify(
bytes32 hash,
bytes calldata signature,
bytes calldata publicKey
) external view returns (bool valid);
/// @notice Recover public key from ECDSA signature
/// @param hash The 32-byte message hash
/// @param signature The 65-byte signature with recovery id
/// @return publicKey The recovered 65-byte uncompressed public key
function ecdsaRecover(
bytes32 hash,
bytes calldata signature
) external view returns (bytes memory publicKey);
/// @notice Batch verify multiple ECDSA signatures
/// @param hashes Array of message hashes
/// @param signatures Array of signatures
/// @param publicKeys Array of public keys
/// @return valid True if ALL signatures are valid
function ecdsaVerifyBatch(
bytes32[] calldata hashes,
bytes[] calldata signatures,
bytes[] calldata publicKeys
) external view returns (bool valid);
/// @notice Verify a BIP-340 Schnorr signature
/// @param hash The 32-byte message hash
/// @param signature The 64-byte Schnorr signature
/// @param publicKey The 32-byte x-only public key
/// @return valid True if signature is valid
function schnorrVerify(
bytes32 hash,
bytes calldata signature,
bytes calldata publicKey
) external view returns (bool valid);
/// @notice Batch verify Schnorr signatures
function schnorrVerifyBatch(
bytes32[] calldata hashes,
bytes[] calldata signatures,
bytes[] calldata publicKeys
) external view returns (bool valid);
/// @notice Multiply a point by a scalar
/// @param point The curve point (33 or 65 bytes)
/// @param scalar The 32-byte scalar
/// @return result The resulting point
function pointMul(
bytes calldata point,
bytes32 scalar
) external view returns (bytes memory result);
/// @notice Add two points
function pointAdd(
bytes calldata point1,
bytes calldata point2
) external view returns (bytes memory result);
/// @notice Negate a point
function pointNeg(
bytes calldata point
) external view returns (bytes memory result);
/// @notice Check if point is on curve
function isOnCurve(
bytes calldata point
) external view returns (bool valid);
/// @notice Create public key from private key
function pubkeyCreate(
bytes32 privateKey
) external view returns (bytes memory publicKey);
/// @notice Serialize public key
function pubkeySerialize(
bytes calldata publicKey,
bool compressed
) external view returns (bytes memory serialized);
/// @notice Parse and validate public key
function pubkeyParse(
bytes calldata publicKey
) external view returns (bytes memory normalized);
/// @notice Add tweak to public key (for BIP-32)
function pubkeyTweakAdd(
bytes calldata publicKey,
bytes32 tweak
) external view returns (bytes memory result);
/// @notice Multiply public key by tweak
function pubkeyTweakMul(
bytes calldata publicKey,
bytes32 tweak
) external view returns (bytes memory result);
/// @notice Combine multiple public keys (for MuSig)
function pubkeyCombine(
bytes[] calldata publicKeys
) external view returns (bytes memory combined);
}
// Convenience library for common operations
library Secp256k1Lib {
address constant SECP256K1 = 0x0000000000000000000000000000000000000312;
function verify(bytes32 hash, bytes memory sig, bytes memory pubkey) internal view returns (bool) {
(bool success, bytes memory result) = SECP256K1.staticcall(
abi.encodePacked(bytes1(0x01), hash, sig, pubkey)
);
return success && abi.decode(result, (bool));
}
function recover(bytes32 hash, bytes memory sig) internal view returns (bytes memory) {
(bool success, bytes memory result) = SECP256K1.staticcall(
abi.encodePacked(bytes1(0x02), hash, sig)
);
require(success, "Recovery failed");
return result;
}
}
Full Implementation Stack
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ Solidity Interface │
│ ISecp256k1.sol → Secp256k1Lib.sol → Secp256k1Verifier.sol │
└─────────────────────────────────┬───────────────────────────────────────┘
│ staticcall
┌─────────────────────────────────▼───────────────────────────────────────┐
│ EVM Precompile Layer (Go) │
│ precompiles/secp256k1/contract.go → Run() → dispatch by selector │
└─────────────────────────────────┬───────────────────────────────────────┘
│ CGO
┌─────────────────────────────────▼───────────────────────────────────────┐
│ Go Wrapper Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ ecdsa.go │ │ schnorr.go │ │ points.go │ │ pubkey.go │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────────┘ │
└─────────────────────────────────┬───────────────────────────────────────┘
│ CGO FFI
┌─────────────────────────────────▼───────────────────────────────────────┐
│ libsecp256k1 (C) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ secp256k1.c │ │ ecmult.c │ │ field_impl.h │ │ scalar.c │ │
│ │ secp256k1.h │ │ group_impl.h │ │ field_10x26 │ │ schnorrsig.c │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
Layer 1: EVM Precompile (Go)
| File | Purpose | Lines |
|---|---|---|
precompiles/secp256k1/contract.go | Main precompile contract | ~450 |
precompiles/secp256k1/config.go | Gas costs and configuration | ~80 |
precompiles/secp256k1/module.go | StatefulPrecompiledContract | ~120 |
precompiles/secp256k1/ecdsa.go | ECDSA operations | ~200 |
precompiles/secp256k1/schnorr.go | Schnorr/BIP-340 operations | ~180 |
precompiles/secp256k1/points.go | Point arithmetic | ~150 |
Layer 2: Go Crypto Wrapper
| File | Purpose | Key Functions |
|---|---|---|
crypto/secp256k1/secp256k1.go | Main wrapper | Sign(), Verify(), RecoverPubkey() |
crypto/secp256k1/schnorr.go | BIP-340 Schnorr | SchnorrSign(), SchnorrVerify() |
crypto/secp256k1/pubkey.go | Public key ops | ParsePubKey(), SerializePubKey() |
crypto/secp256k1/privkey.go | Private key ops | PrivKeyFromBytes(), GeneratePrivKey() |
crypto/secp256k1/field.go | Field arithmetic | FieldVal, SetBytes(), Normalize() |
crypto/secp256k1/curve.go | Curve parameters | S256(), curve constants |
Layer 3: libsecp256k1 (C) - ~24MB compiled
| File | Purpose | Size |
|---|---|---|
src/secp256k1.c | Main implementation | 42 KB |
src/ecmult_impl.h | EC multiplication | 28 KB |
src/ecmult_gen_impl.h | Generator multiplication | 15 KB |
src/field_10x26_impl.h | Field arithmetic (32-bit) | 35 KB |
src/field_5x52_impl.h | Field arithmetic (64-bit) | 25 KB |
src/scalar_4x64_impl.h | Scalar arithmetic | 18 KB |
src/group_impl.h | Group operations | 22 KB |
src/modinv64_impl.h | Modular inverse | 12 KB |
include/secp256k1.h | Public API | 8 KB |
include/secp256k1_schnorrsig.h | Schnorr API | 4 KB |
include/secp256k1_recovery.h | Recovery API | 2 KB |
include/secp256k1_extrakeys.h | Extra key ops | 3 KB |
Total Library Size: ~24 MB (compiled with all modules)
Implementation Files
~/work/lux/
├── crypto/secp256k1/
│ ├── libsecp256k1/ # Bitcoin Core's libsecp256k1
│ │ ├── src/ # C implementation (~24 MB compiled)
│ │ ├── include/ # Public headers
│ │ └── Makefile # Build configuration
│ ├── secp256k1.go # CGO bindings
│ ├── secp256k1_cgo.go # CGO import wrapper
│ ├── schnorr.go # BIP-340 Schnorr
│ ├── pubkey.go # Public key operations
│ ├── privkey.go # Private key operations
│ ├── signature.go # Signature types
│ └── secp256k1_test.go # Comprehensive tests
├── geth/core/vm/
│ └── contracts_secp256k1.go # Precompile registration
└── precompiles/secp256k1/
├── contract.go # Main precompile
├── config.go # Configuration
└── module.go # Module registration
Secure Implementation Guidelines
Constant-Time Operations (CRITICAL)
// libsecp256k1/src/util.h
// All operations are constant-time to prevent timing attacks
/** Semantics like memcmp. Variable-time. */
static SECP256K1_INLINE int secp256k1_memcmp_var(const void *s1, const void *s2, size_t n) {
const unsigned char *p1 = s1, *p2 = s2;
size_t i;
for (i = 0; i < n; i++) {
int diff = p1[i] - p2[i];
if (diff != 0) return diff;
}
return 0;
}
/** Constant-time comparison - ALWAYS use for secret data */
static SECP256K1_INLINE int secp256k1_memczero(void *s, size_t len, int flag) {
unsigned char *p = (unsigned char *)s;
unsigned char mask = -(unsigned char)flag;
size_t i;
for (i = 0; i < len; i++) {
p[i] &= ~mask;
}
return 1;
}
Secure Scalar Handling
// crypto/secp256k1/scalar.go
// CRITICAL: Scalars must be validated and reduced modulo curve order
func (s *Scalar) SetBytes(b []byte) error {
if len(b) != 32 {
return ErrInvalidScalarLength
}
// Check for overflow (scalar >= curve order)
var overflow int
C.secp256k1_scalar_set_b32(
(*C.secp256k1_scalar)(unsafe.Pointer(&s.inner)),
(*C.uchar)(unsafe.Pointer(&b[0])),
(*C.int)(unsafe.Pointer(&overflow)),
)
if overflow != 0 {
return ErrScalarOverflow
}
// Check for zero scalar (invalid for signatures)
if C.secp256k1_scalar_is_zero((*C.secp256k1_scalar)(unsafe.Pointer(&s.inner))) != 0 {
return ErrZeroScalar
}
return nil
}
// Secure memory zeroing after use
func (s *Scalar) Clear() {
C.secp256k1_scalar_clear((*C.secp256k1_scalar)(unsafe.Pointer(&s.inner)))
}
Signature Malleability Prevention
// crypto/secp256k1/ecdsa.go
// CRITICAL: Enforce low-S signatures (BIP-62/BIP-146)
func NormalizeLowS(sig *Signature) *Signature {
// secp256k1 curve order n
// If s > n/2, replace s with n - s
halfOrder := new(big.Int).Rsh(secp256k1Order, 1)
if sig.S.Cmp(halfOrder) > 0 {
sig.S.Sub(secp256k1Order, sig.S)
}
return sig
}
// Verify signature has canonical low-S form
func IsLowS(sig *Signature) bool {
halfOrder := new(big.Int).Rsh(secp256k1Order, 1)
return sig.S.Cmp(halfOrder) <= 0
}
Nonce Generation (CRITICAL - k reuse = key recovery)
// crypto/secp256k1/ecdsa.go
// RFC 6979 deterministic nonce generation
// NEVER use random nonce without proper entropy
func generateNonceRFC6979(privKey *PrivateKey, hash []byte) (*Scalar, error) {
// libsecp256k1 implements RFC 6979 internally
// Do NOT implement custom nonce generation
var nonce C.secp256k1_nonce_function_rfc6979
// Additional entropy can be added for extra security
var extraEntropy [32]byte
if _, err := rand.Read(extraEntropy[:]); err != nil {
return nil, fmt.Errorf("entropy generation failed: %w", err)
}
// libsecp256k1 combines message hash, private key, and entropy
// using HMAC-SHA256 per RFC 6979
return nonceFromRFC6979(hash, privKey.Bytes(), extraEntropy[:])
}
Side-Channel Resistant Point Multiplication
// libsecp256k1/src/ecmult_impl.h
// Windowed NAF (wNAF) with constant-time table lookups
// Prevents simple power analysis (SPA) and differential power analysis (DPA)
static void secp256k1_ecmult(
secp256k1_gej *r,
const secp256k1_gej *a,
const secp256k1_scalar *na,
const secp256k1_scalar *ng
) {
// GLV endomorphism for faster multiplication
// β = cube root of unity
// λ·P = (β·x, y) for any point P
// Split scalar: n = n1 + λ·n2 where |n1|, |n2| < sqrt(n)
secp256k1_scalar n1, n2;
secp256k1_scalar_split_lambda(&n1, &n2, na);
// Double-scalar multiplication: n1·P + n2·λ·P
// Constant-time implementation using precomputed table
secp256k1_ecmult_strauss_wnaf(r, a, &n1, &n2, ng);
}
Public Key Validation
// crypto/secp256k1/pubkey.go
func ParsePubKey(pubKeyBytes []byte) (*PublicKey, error) {
// Validate length
if len(pubKeyBytes) != 33 && len(pubKeyBytes) != 65 {
return nil, ErrInvalidPubKeyLength
}
// Validate prefix
if len(pubKeyBytes) == 33 {
if pubKeyBytes[0] != 0x02 && pubKeyBytes[0] != 0x03 {
return nil, ErrInvalidPubKeyPrefix
}
} else {
if pubKeyBytes[0] != 0x04 {
return nil, ErrInvalidPubKeyPrefix
}
}
// Parse and validate point is on curve
var pubKey C.secp256k1_pubkey
if C.secp256k1_ec_pubkey_parse(
C.secp256k1_context_no_precomp,
&pubKey,
(*C.uchar)(unsafe.Pointer(&pubKeyBytes[0])),
C.size_t(len(pubKeyBytes)),
) != 1 {
return nil, ErrInvalidPubKey
}
return &PublicKey{inner: pubKey}, nil
}
Integration Across Lux Infrastructure
Layer Integration Points
| Layer | Component | secp256k1 Integration | Purpose |
|---|---|---|---|
| EVM | precompiles/secp256k1/ | Precompile contract | Smart contract access |
| Crypto | crypto/secp256k1/ | Core library (~24 MB) | Signing, verification |
| Threshold | threshold/pkg/math/curve/ | Curve operations | FROST, CGGMP21 |
| Node | node/crypto/ | Transaction signing | P-Chain, X-Chain |
| Wallet | wallet/keys/ | Key management | HD derivation |
| Bridge | bridge/custody/ | Multi-sig | Cross-chain security |
Network Usage Map
| Lux Component | secp256k1 Operations | Use Case |
|---|---|---|
| C-Chain Transactions | ECDSA Sign/Verify | Ethereum-compatible txs |
| P-Chain Staking | ECDSA Sign | Validator registration |
| X-Chain Assets | ECDSA Sign | UTXO transfers |
| FROST Threshold | Point multiplication | Distributed signing |
| CGGMP21 ECDSA | Full ECDSA operations | Threshold ECDSA |
| Cross-Chain Warp | Batch verification | Message attestation |
| Bitcoin Bridges | Schnorr (Taproot) | BTC custody |
Performance Benchmarks (Apple M1 Max)
| Operation | Time | Throughput |
|---|---|---|
| ECDSA Sign | 45 μs | 22,222 ops/sec |
| ECDSA Verify | 65 μs | 15,385 ops/sec |
| Public Key Recovery | 70 μs | 14,286 ops/sec |
| Schnorr Sign | 42 μs | 23,810 ops/sec |
| Schnorr Verify | 58 μs | 17,241 ops/sec |
| Point Multiply | 35 μs | 28,571 ops/sec |
| Batch Verify (10) | 450 μs | 22,222 sigs/sec |
Test Cases
contract Secp256k1Test {
ISecp256k1 constant SECP = ISecp256k1(0x0000000000000000000000000000000000000312);
function testECDSAVerify() public view {
bytes32 hash = keccak256("test message");
bytes memory sig = hex"..."; // 65-byte signature
bytes memory pubkey = hex"..."; // 33-byte compressed pubkey
bool valid = SECP.ecdsaVerify(hash, sig, pubkey);
require(valid, "ECDSA verify failed");
}
function testSchnorrVerify() public view {
bytes32 hash = keccak256("test message");
bytes memory sig = hex"..."; // 64-byte Schnorr signature
bytes memory pubkey = hex"..."; // 32-byte x-only pubkey
bool valid = SECP.schnorrVerify(hash, sig, pubkey);
require(valid, "Schnorr verify failed");
}
function testBatchVerify() public view {
bytes32[] memory hashes = new bytes32[](3);
bytes[] memory sigs = new bytes[](3);
bytes[] memory pubkeys = new bytes[](3);
// Fill arrays...
bool valid = SECP.ecdsaVerifyBatch(hashes, sigs, pubkeys);
require(valid, "Batch verify failed");
}
function testPointOperations() public view {
bytes memory G = hex"0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798";
bytes32 scalar = bytes32(uint256(2));
bytes memory result = SECP.pointMul(G, scalar);
// result should be 2G
bytes memory sum = SECP.pointAdd(G, result);
// sum should be 3G
}
}
Security Considerations
- Constant-Time Operations: All operations use libsecp256k1's constant-time implementations
- Nonce Security: RFC 6979 deterministic nonces prevent k-reuse attacks
- Low-S Enforcement: BIP-62/BIP-146 malleability fix enforced
- Public Key Validation: All public keys validated to be on curve
- Scalar Range Checking: Scalars validated to be in [1, n-1]
- Memory Security: Sensitive data cleared after use
Backwards Compatibility
This LP introduces a new precompile and is fully backwards compatible. Existing contracts using ecrecover continue to function unchanged.
References
- libsecp256k1 - Bitcoin Core's implementation
- BIP-340 - Schnorr Signatures
- BIP-62 - Dealing with malleability
- RFC 6979 - Deterministic DSA/ECDSA
- LP-7321: FROST Threshold Signature Precompile
- LP-7322: CGGMP21 Threshold ECDSA Precompile
Copyright
Copyright and related rights waived via CC0.