Skip to main content

Try it Live

Run Secp256k1 examples in the interactive playground
This page is a placeholder. All examples on this page are currently AI-generated and are not correct. This documentation will be completed in the future with accurate, tested examples.

Examples

Secp256k1 Verification

Verify ECDSA signatures against public keys to authenticate messages. Signature verification is the cornerstone of Ethereum’s security model - every transaction must pass verification before execution.

Overview

ECDSA verification confirms that a signature was created by the private key corresponding to a given public key. The verifier needs:
  • Signature (r, s, v) - 65 bytes total
  • Message hash - 32 bytes (what was signed)
  • Public key - 64 bytes uncompressed (x || y coordinates)
Verification succeeds if the signature was created by the matching private key, fails otherwise. No secret information is revealed during verification - it’s safe to perform publicly.

API

verify(signature, messageHash, publicKey)

Verify an ECDSA signature against a message hash and public key. Parameters:
  • signature (BrandedSignature) - Signature with r, s, v components
  • messageHash (HashType) - 32-byte hash that was signed
  • publicKey (Uint8Array) - 64-byte uncompressed public key (x || y)
Returns: boolean
  • true - Signature is cryptographically valid
  • false - Signature is invalid or forged
Throws:
  • InvalidPublicKeyError - Public key wrong length or not on curve
  • InvalidSignatureError - Signature components wrong length
Example:
import * as Secp256k1 from '@tevm/voltaire/Secp256k1';
import { Keccak256 } from '@tevm/voltaire/Keccak256';

// Sign message
const privateKey = Bytes32();
crypto.getRandomValues(privateKey);
const messageHash = Keccak256.hashString('Verify me!');
const signature = Secp256k1.sign(messageHash, privateKey);

// Verify with public key
const publicKey = Secp256k1.derivePublicKey(privateKey);
const isValid = Secp256k1.verify(signature, messageHash, publicKey);

console.log(isValid); // true

// Verify with wrong public key
const wrongKey = Bytes64();
const invalid = Secp256k1.verify(signature, messageHash, wrongKey);
console.log(invalid); // false

Algorithm Details

ECDSA Verification

  1. Validate inputs:
    • Check 1 ≤ r < n and 1 ≤ s < n (signature component bounds)
    • Verify public key is a valid curve point (satisfies y² = x³ + 7)
  2. Compute message hash scalar: e = hash(message) mod n
  3. Calculate inverse: s_inv = s^-1 mod n
  4. Compute point: R = (e * s_inv) * G + (r * s_inv) * public_key
    • G is the generator point
    • public_key is the signer’s public key point
  5. Verify: Check if R.x mod n == r
    • If equal, signature is valid
    • If not equal, signature is invalid or forged

Why Verification Works

The signature was created as: s = k^-1 * (e + r * private_key) mod n Rearranging: k = s^-1 * (e + r * private_key) mod n Since R = k * G and public_key = private_key * G:
R = k * G
  = s^-1 * (e + r * private_key) * G
  = s^-1 * e * G + s^-1 * r * private_key * G
  = (e * s^-1) * G + (r * s^-1) * public_key
This matches step 4 above. If the signature is valid, R.x == r.

Validation Checks

Signature Component Validation

function isValidSignatureComponents(r: bigint, s: bigint): boolean {
  const n = SECP256K1_N; // Curve order

  // r must be in [1, n-1]
  if (r < 1n || r >= n) return false;

  // s must be in [1, n-1]
  if (s < 1n || s >= n) return false;

  // Ethereum enforces low-s (s ≤ n/2) to prevent malleability
  if (s > n / 2n) return false;

  return true;
}
Invalid components always fail verification.

Public Key Validation

function isValidPublicKey(pubkey: Uint8Array): boolean {
  if (pubkey.length !== 64) return false;

  // Parse x and y coordinates
  const x = bytesToBigInt(pubkey.slice(0, 32));
  const y = bytesToBigInt(pubkey.slice(32, 64));

  // Check point is on curve: y² = x³ + 7 (mod p)
  const p = SECP256K1_P; // Field prime
  const y2 = (y * y) % p;
  const x3_plus_7 = (x * x * x + 7n) % p;

  return y2 === x3_plus_7;
}
Invalid public keys (not on curve) always fail verification.

Security Considerations

Malleability and Low-s

ECDSA signatures have inherent malleability: both (r, s) and (r, n - s) are valid for the same message and key. This can cause issues: Problem:
// Original signature
const sig1 = { r, s: s, v: 27 };
const valid1 = verify(sig1, hash, pubkey); // true

// Malleated signature (different bytes, same validity)
const sig2 = { r, s: CURVE_ORDER - s, v: 28 };
const valid2 = verify(sig2, hash, pubkey); // also true!
Solution: Ethereum enforces low-s (s ≤ n/2):
if (s > CURVE_ORDER / 2n) {
  // Reject high-s signatures
  return false;
}
All signatures created by sign() use low-s. Verification accepts only low-s.

Recovery ID (v) Not Required

The v component is only needed for public key recovery (ecRecover). Standard verification ignores it because the public key is already provided.
// v is ignored during verification (only r and s matter)
const sig1 = { r, s, v: 27 };
const sig2 = { r, s, v: 28 };

// Both verify the same way if public key is provided
verify(sig1, hash, pubkey) === verify(sig2, hash, pubkey);
For recovery-based verification (like Ethereum’s ecRecover), v is critical.

Public Key Format

Secp256k1 public keys can be represented in multiple formats: Uncompressed (65 bytes): 0x04 || x || y
  • Standard format with 0x04 prefix
  • Contains both x and y coordinates
Uncompressed without prefix (64 bytes): x || y
  • Voltaire’s internal format (no prefix)
  • Used by our verification API
Compressed (33 bytes): 0x02 || x or 0x03 || x
  • Only x-coordinate + parity bit for y
  • Not directly supported (must decompress first)
Our API expects 64-byte keys (no prefix). If you have prefixed keys:
// Remove 0x04 prefix
if (publicKey.length === 65 && publicKey[0] === 0x04) {
  publicKey = publicKey.slice(1);
}

Test Vectors

Basic Verification

const privateKey = Bytes32();
privateKey[31] = 42;

const messageHash = Keccak256.hashString("test message");
const signature = Secp256k1.sign(messageHash, privateKey);
const publicKey = Secp256k1.derivePublicKey(privateKey);

// Correct public key: verification succeeds
assert(Secp256k1.verify(signature, messageHash, publicKey) === true);

Wrong Public Key

// Different private key
const wrongPrivateKey = Bytes32();
wrongPrivateKey[31] = 99;
const wrongPublicKey = Secp256k1.derivePublicKey(wrongPrivateKey);

// Wrong public key: verification fails
assert(Secp256k1.verify(signature, messageHash, wrongPublicKey) === false);

Wrong Message

const originalHash = Keccak256.hashString("original");
const signature = Secp256k1.sign(originalHash, privateKey);

const differentHash = Keccak256.hashString("different");

// Wrong message hash: verification fails
assert(Secp256k1.verify(signature, differentHash, publicKey) === false);

Malleated Signature

const signature = Secp256k1.sign(messageHash, privateKey);

// Create malleated signature (r, n - s)
const r = bytesToBigInt(signature.r);
const s = bytesToBigInt(signature.s);
const malleatedS = SECP256K1_N - s;

const malleatedSig = {
  r: signature.r,
  s: bigIntToBytes(malleatedS, 32),
  v: signature.v ^ 1, // Flip recovery ID
};

// Malleated signature (high-s): verification fails
assert(Secp256k1.verify(malleatedSig, messageHash, publicKey) === false);

Invalid Signature Components

// r = 0 (invalid)
const invalidR = {
  r: Bytes32(), // All zeros
  s: signature.s,
  v: 27,
};
assert(Secp256k1.verify(invalidR, messageHash, publicKey) === false);

// s >= n (invalid)
const invalidS = {
  r: signature.r,
  s: Bytes32().fill(0xff), // All 0xff > n
  v: 27,
};
assert(Secp256k1.verify(invalidS, messageHash, publicKey) === false);

Performance

Verification Cost

ECDSA verification is computationally expensive:
  1. Modular inversion - s^-1 mod n (expensive)
  2. Two scalar multiplications - u1 * G + u2 * public_key
  3. Point operations - Elliptic curve point addition
Typical verification time:
  • TypeScript (@noble/curves): ~1-2ms per signature
  • Zig (native): ~0.5-1ms per signature
  • WASM (portable): ~2-4ms per signature
For batch verification of multiple signatures, use optimized batch algorithms (not currently exposed in API).

EVM Precompile

Ethereum provides ecRecover precompile (address 0x01) for on-chain verification:
  • Gas cost: 3000 gas
  • Input: 128 bytes (hash, v, r, s)
  • Output: 32 bytes (recovered address, zero-padded)
For smart contracts, use ecrecover() built-in instead of implementing verification in Solidity.

Implementation Notes

TypeScript

Uses @noble/curves/secp256k1:
  • Constant-time operations (side-channel resistant)
  • Validates all inputs (signature components, public keys)
  • Enforces low-s malleability protection
  • ~20KB minified, tree-shakeable

Zig

Custom implementation:
  • ⚠️ UNAUDITED - Not security reviewed
  • ⚠️ NOT constant-time - Timing attack vulnerable
  • Basic validation only
  • Educational purposes only