Skip to main content

Examples

Secp256k1 Public Key Recovery

Recover the signer’s public key from an ECDSA signature and message hash. This is the core mechanism of Ethereum’s ecRecover precompile and enables address-based authentication without storing public keys on-chain.

Overview

ECDSA signatures contain enough information to recover the signer’s public key:
  • Signature (r, s, v) - 65 bytes
  • Message hash - 32 bytes
From these, we can compute the public key without knowing the private key. This enables:
  • ecRecover precompile - On-chain signature verification (address 0x01)
  • Transaction authentication - Derive sender address from transaction signature
  • Message signing - Verify signed messages (EIP-191, EIP-712)
  • Compact storage - Store signatures instead of public keys

API

recoverPublicKey(signature, messageHash)

Recover the 64-byte public key from a signature and message hash. Parameters:
  • signature (BrandedSignature) - Signature with r, s, v components
  • messageHash (HashType) - 32-byte hash that was signed
Returns: Uint8Array - 64-byte uncompressed public key (x || y) Throws:
  • InvalidSignatureError - Invalid signature format or recovery failed
  • InvalidHashError - Hash wrong length
Example:
import * as Secp256k1 from '@tevm/voltaire/crypto/Secp256k1';
import { Keccak256 } from '@tevm/voltaire/crypto/Keccak256';

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

// Recover public key (without knowing private key)
const recoveredKey = Secp256k1.recoverPublicKey(signature, messageHash);

// Verify recovery succeeded
const actualKey = Secp256k1.derivePublicKey(privateKey);
console.log(recoveredKey.every((byte, i) => byte === actualKey[i])); // true

Algorithm Details

ECDSA Public Key Recovery

Given signature (r, s, v) and message hash e:
  1. Reconstruct R point from r:
    • r is the x-coordinate of ephemeral point R = k * G
    • Solve for y: y² = x³ + 7 mod p (curve equation)
    • Two possible y values (positive and negative)
    • Recovery ID v selects which y to use
  2. Calculate helper values:
    • r_inv = r^-1 mod n (modular inverse of r)
    • e_neg = -e mod n (negation of message hash)
  3. Recover public key:
    public_key = r_inv * (s * R + e_neg * G)
    
    Where:
    • R is the reconstructed point
    • G is the generator point
    • * denotes scalar multiplication
  4. Verify recovery:
    • Check recovered key is valid curve point
    • Optionally verify signature with recovered key

Why Recovery 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
s * k = e + r * private_key mod n
s * k - e = r * private_key mod n
private_key = r^-1 * (s * k - e) mod n
Since public_key = private_key * G and R = k * G:
public_key = r^-1 * (s * k - e) * G
           = r^-1 * (s * (k * G) - e * G)
           = r^-1 * (s * R - e * G)
This matches step 3 above.

Recovery ID (v)

The recovery ID v resolves ambiguities in recovery: Two y-coordinates: For each x-coordinate r, there are two possible y-values satisfying the curve equation (y and p - y). The recovery ID selects which one. Ethereum format:
  • v = 27: Use y with even parity (y & 1 == 0)
  • v = 28: Use y with odd parity (y & 1 == 1)
Standard format:
  • v = 0: Even parity
  • v = 1: Odd parity
EIP-155 (replay protection):
  • v = chainId * 2 + 35: Even parity
  • v = chainId * 2 + 36: Odd parity
Our API accepts all formats and normalizes internally.

Ethereum Integration

ecRecover Precompile

Ethereum provides a precompiled contract at address 0x0000000000000000000000000000000000000001 for on-chain recovery: Solidity:
function ecrecover(
    bytes32 hash,
    uint8 v,
    bytes32 r,
    bytes32 s
) public pure returns (address) {
    // Returns signer address or 0x0 if invalid
}
Gas cost: 3000 gas Example:
function verifySigner(
    bytes32 messageHash,
    uint8 v,
    bytes32 r,
    bytes32 s,
    address expectedSigner
) public pure returns (bool) {
    address signer = ecrecover(messageHash, v, r, s);
    return signer == expectedSigner;
}

Transaction Sender Recovery

Every Ethereum transaction signature enables sender recovery:
import * as Secp256k1 from '@tevm/voltaire/crypto/Secp256k1';
import * as Address from '@tevm/voltaire/primitives/Address';
import * as Transaction from '@tevm/voltaire/primitives/Transaction';

// Parse transaction
const tx = {
  nonce: 0n,
  gasPrice: 20000000000n,
  gasLimit: 21000n,
  to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
  value: 1000000000000000000n,
  data: new Uint8Array(),
  v: 27,
  r: Bytes32(), // from transaction
  s: Bytes32(), // from transaction
};

// Recover sender public key
const txHash = Transaction.hash(tx);
const signature = { r: tx.r, s: tx.s, v: tx.v };
const publicKey = Secp256k1.recoverPublicKey(signature, txHash);

// Derive sender address
const senderAddress = Address.fromPublicKey(publicKey);
console.log(Address.toHex(senderAddress));

EIP-191 Personal Sign

Recover signer from personal_sign messages:
import { Keccak256 } from '@tevm/voltaire/crypto/Keccak256';

function recoverPersonalSignSigner(
  message: string,
  signature: { r: Uint8Array; s: Uint8Array; v: number }
): Uint8Array {
  // EIP-191: "\x19Ethereum Signed Message:\n" + len(message) + message
  const prefix = `\x19Ethereum Signed Message:\n${message.length}`;
  const prefixedMessage = new TextEncoder().encode(prefix + message);

  // Hash prefixed message
  const messageHash = Keccak256.hash(prefixedMessage);

  // Recover public key
  return Secp256k1.recoverPublicKey(signature, messageHash);
}

// Usage
const message = "Sign this message";
const signature = { r, s, v }; // From wallet
const publicKey = recoverPersonalSignSigner(message, signature);
const address = Address.fromPublicKey(publicKey);

EIP-712 Typed Data

Recover signer from typed structured data:
import * as EIP712 from '@tevm/voltaire/crypto/EIP712';

function recoverTypedDataSigner(
  domain: EIP712.Domain,
  types: EIP712.Types,
  message: any,
  signature: { r: Uint8Array; s: Uint8Array; v: number }
): Uint8Array {
  // Hash typed data
  const messageHash = EIP712.hashTypedData(domain, types, message);

  // Recover public key
  return Secp256k1.recoverPublicKey(signature, messageHash);
}

Security Considerations

Recovery Uniqueness

Critical: Recovery is only unique with the correct v value. Wrong v recovers a different (invalid) public key.
const signature1 = { r, s, v: 27 };
const signature2 = { r, s, v: 28 };

const key1 = Secp256k1.recoverPublicKey(signature1, messageHash);
const key2 = Secp256k1.recoverPublicKey(signature2, messageHash);

// Different v = different recovered keys (only one is correct)
console.log(key1.every((byte, i) => byte === key2[i])); // false
Always use the v value from the original signature.

Malleability Protection

Signature malleability affects recovery: Original signature:
const sig1 = { r, s, v: 27 };
const recovered1 = Secp256k1.recoverPublicKey(sig1, hash);
Malleated signature:
const sig2 = { r, s: CURVE_ORDER - s, v: 28 }; // High-s
const recovered2 = Secp256k1.recoverPublicKey(sig2, hash);
Both recover different public keys. Ethereum enforces low-s to prevent this.

Invalid Signature Handling

Invalid signatures can:
  • Return incorrect public keys
  • Throw errors during recovery
  • Recover keys not on the curve
Always verify recovered keys:
try {
  const publicKey = Secp256k1.recoverPublicKey(signature, messageHash);

  // Verify key is valid
  if (!Secp256k1.isValidPublicKey(publicKey)) {
    throw new Error('Recovered invalid public key');
  }

  // Optionally verify signature with recovered key
  if (!Secp256k1.verify(signature, messageHash, publicKey)) {
    throw new Error('Signature verification failed');
  }

  // Use recovered key
  const address = Address.fromPublicKey(publicKey);
} catch (error) {
  console.error('Recovery failed:', error);
}

Test Vectors

Basic Recovery

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

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

// Recover public key
const recovered = Secp256k1.recoverPublicKey(signature, messageHash);

// Verify matches original
const actual = Secp256k1.derivePublicKey(privateKey);
assert(recovered.every((byte, i) => byte === actual[i]));

Recovery ID Selection

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

// v = 27 (correct)
const correctV = { ...signature, v: 27 };
const key1 = Secp256k1.recoverPublicKey(correctV, messageHash);

// v = 28 (incorrect for this signature)
const incorrectV = { ...signature, v: 28 };
const key2 = Secp256k1.recoverPublicKey(incorrectV, messageHash);

// Different keys recovered
assert(!key1.every((byte, i) => byte === key2[i]));

// Only correct v matches actual public key
const actualKey = Secp256k1.derivePublicKey(privateKey);
const match1 = key1.every((byte, i) => byte === actualKey[i]);
const match2 = key2.every((byte, i) => byte === actualKey[i]);

assert(match1 !== match2); // Exactly one matches

EIP-191 Personal Sign

// Sign message
const message = "Hello, Ethereum!";
const prefix = `\x19Ethereum Signed Message:\n${message.length}`;
const prefixedMessage = new TextEncoder().encode(prefix + message);
const messageHash = Keccak256.hash(prefixedMessage);
const signature = Secp256k1.sign(messageHash, privateKey);

// Recover signer
const recovered = Secp256k1.recoverPublicKey(signature, messageHash);
const recoveredAddress = Address.fromPublicKey(recovered);

// Verify matches expected
const expectedAddress = Address.fromPublicKey(
  Secp256k1.derivePublicKey(privateKey)
);

assert(Address.equals(recoveredAddress, expectedAddress));

Invalid Signature Recovery

// Invalid r (all zeros)
const invalidR = {
  r: Bytes32(),
  s: signature.s,
  v: 27,
};
expect(() => Secp256k1.recoverPublicKey(invalidR, messageHash)).toThrow();

// Invalid s (too large)
const invalidS = {
  r: signature.r,
  s: Bytes32().fill(0xff),
  v: 27,
};
expect(() => Secp256k1.recoverPublicKey(invalidS, messageHash)).toThrow();

Performance

Public key recovery is more expensive than verification:
  • Verification: Requires 2 scalar multiplications
  • Recovery: Requires 2 scalar multiplications + modular square root
Typical recovery time:
  • TypeScript (@noble/curves): ~1-2ms per signature
  • Zig (native): ~0.5-1ms per signature
  • WASM (portable): ~2-4ms per signature
  • EVM (ecRecover precompile): 3000 gas (~60µs at 50M gas/sec)
For verification-only use cases, prefer verify() with known public key over recovery.

Implementation Notes

TypeScript

Uses @noble/curves/secp256k1:
  • Implements recovery via point reconstruction
  • Handles both standard (0/1) and Ethereum (27/28) v values
  • Validates recovered keys before returning
  • Constant-time operations

Zig

Custom implementation:
  • ⚠️ UNAUDITED - Not security reviewed
  • Implements modular square root for y recovery
  • Basic validation only
  • Educational purposes only