Skip to main content

Try it Live

Run Signature examples in the interactive playground

Signature Recovery

Recover public keys and addresses from ECDSA signatures using the recovery ID (v parameter).

Overview

ECDSA signatures allow recovering the signer’s public key from the signature and message hash. This is a key feature of Ethereum transactions, enabling address derivation without storing public keys. Supported Algorithms:
  • ✅ secp256k1 (Ethereum, Bitcoin)
  • ❌ P-256 (no recovery support)
  • ❌ Ed25519 (no recovery support)

Recovery Process

Public Key Recovery

Secp256k1 signatures can recover the public key that created the signature:
import { Signature } from 'tevm';
import { Secp256k1 } from 'tevm/crypto';

// Transaction signature
const sig = Signature.fromSecp256k1(r, s, 27);

// Message hash that was signed
const messageHash = keccak256(message);

// Recover public key
const publicKey = Secp256k1.recoverPublicKey(
  sig,
  messageHash
);

console.log(publicKey.length); // 65 bytes (uncompressed) or 33 bytes (compressed)
See: Secp256k1.recoverPublicKey in crypto module

Address Recovery

Ethereum addresses are derived from public keys via keccak256 hash:
import { Signature, Address } from 'tevm';
import { Secp256k1, keccak256 } from 'tevm/crypto';

const sig = Signature.fromSecp256k1(r, s, v);
const messageHash = keccak256(message);

// Recover address directly
const address = Secp256k1.recoverAddress(sig, messageHash);

console.log(Address.isValid(address)); // true
See: Secp256k1.recoverAddress in crypto module

Recovery ID (v)

The recovery ID determines which of four possible public keys is correct.

Standard Values

Ethereum (pre-EIP-155):
v = 27  // First recovery attempt (y-coordinate is even)
v = 28  // Second recovery attempt (y-coordinate is odd)
EIP-155 (chain-specific):
v = chainId * 2 + 35 + yParity

// Examples:
// Mainnet (chainId 1): v = 37 or 38
// Görli (chainId 5):   v = 45 or 46
// Polygon (chainId 137): v = 309 or 310

Converting v Values

// EIP-155 to standard recovery ID
function toStandardV(eip155V: number, chainId: number): number {
  if (eip155V < 35) return eip155V; // Already standard
  return ((eip155V - 35 - chainId * 2) % 2) + 27;
}

// Standard to EIP-155
function toEIP155V(standardV: number, chainId: number): number {
  const yParity = standardV === 27 ? 0 : 1;
  return chainId * 2 + 35 + yParity;
}

// Usage
const eip155V = 37; // Mainnet signature
const standardV = toStandardV(eip155V, 1); // 27
const sig = Signature.fromSecp256k1(r, s, standardV);

Recovery Examples

Ethereum Transaction Recovery

import { Signature, Hash } from 'tevm';
import { Secp256k1, keccak256 } from 'tevm/crypto';

// Transaction data
const tx = {
  nonce: 0,
  gasPrice: 20000000000n,
  gasLimit: 21000n,
  to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
  value: 1000000000000000000n, // 1 ETH
  data: '0x',
  r: '0x...',
  s: '0x...',
  v: 27
};

// 1. Hash the transaction (RLP encoded)
const txHash = Hash(keccak256(rlpEncode(tx)));

// 2. Create signature
const sig = Signature.fromSecp256k1(
  Hex.toBytes(tx.r),
  Hex.toBytes(tx.s),
  tx.v
);

// 3. Recover sender address
const sender = Secp256k1.recoverAddress(sig, txHash);
console.log(Address.toString(sender)); // "0x..."

EIP-712 Signed Message Recovery

import { Signature } from 'tevm';
import { EIP712, Secp256k1 } from 'tevm/crypto';

const domain = {
  name: 'My Dapp',
  version: '1',
  chainId: 1,
  verifyingContract: '0x...'
};

const types = {
  Person: [
    { name: 'name', type: 'string' },
    { name: 'wallet', type: 'address' }
  ]
};

const message = {
  name: 'Alice',
  wallet: '0x...'
};

// Hash the structured data
const messageHash = EIP712.hash({ domain, types, message });

// Parse signature from user
const sig = Signature(signatureBytes);

// Recover signer address
const signer = Secp256k1.recoverAddress(sig, messageHash);

Personal Sign Recovery

import { Signature, Hash } from 'tevm';
import { keccak256, Secp256k1 } from 'tevm/crypto';

// Ethereum personal_sign prefixes message
const message = 'Sign this message';
const prefix = `\x19Ethereum Signed Message:\n${message.length}`;
const prefixedMessage = prefix + message;

// Hash the prefixed message
const messageHash = Hash(keccak256(prefixedMessage));

// Recover signer
const sig = Signature(signatureBytes);
const signer = Secp256k1.recoverAddress(sig, messageHash);

Recovery Validation

Verify Recovery Success

// Recover and verify against known address
const expectedAddress = Address('0x...');
const sig = Signature(signatureBytes);
const messageHash = Hash(keccak256(message));

const recoveredAddress = Secp256k1.recoverAddress(sig, messageHash);

if (Address.equals(recoveredAddress, expectedAddress)) {
  console.log('Signature verified');
} else {
  console.log('Invalid signature or wrong signer');
}

Handle Recovery Errors

try {
  const publicKey = Secp256k1.recoverPublicKey(sig, messageHash);
} catch (err) {
  if (err instanceof InvalidSignatureError) {
    console.error('Invalid signature format');
  } else if (err instanceof RecoveryError) {
    console.error('Could not recover public key');
  }
}

Multiple Recovery Attempts

When v is unknown, try all possibilities:
function tryRecoverAddress(
  r: Uint8Array,
  s: Uint8Array,
  messageHash: Hash
): Address | null {
  // Try both standard recovery IDs
  for (const v of [27, 28]) {
    try {
      const sig = Signature.fromSecp256k1(r, s, v);
      const address = Secp256k1.recoverAddress(sig, messageHash);
      // Validate against expected address if known
      return address;
    } catch {
      continue;
    }
  }
  return null;
}

Security Considerations

Canonical Signatures

Always normalize signatures before recovery to prevent malleability:
// Wrong: Non-canonical signature may recover different address
const sig = Signature.fromSecp256k1(r, sHigh, 27);
const address1 = Secp256k1.recoverAddress(sig, messageHash);

// Right: Normalize first
const canonical = Signature.normalize(sig);
const address2 = Secp256k1.recoverAddress(canonical, messageHash);

// address1 !== address2 if signature was non-canonical!
Always normalize before recovery:
function safeRecover(sig: BrandedSignature, messageHash: Hash): Address {
  const canonical = Signature.normalize(sig);
  return Secp256k1.recoverAddress(canonical, messageHash);
}

Message Hash Validation

// Ensure message hash is 32 bytes (keccak256 output)
if (messageHash.length !== 32) {
  throw new Error('Message hash must be 32 bytes');
}

// Verify it's a proper keccak256 hash
const recomputed = keccak256(originalMessage);
if (!Hash.equals(messageHash, recomputed)) {
  throw new Error('Message hash mismatch');
}

Recovery ID Bounds

// Validate v is in expected range
const v = Signature.getV(sig);

if (v === undefined) {
  throw new Error('Recovery ID required for recovery');
}

if (v !== 27 && v !== 28) {
  // Handle EIP-155 encoded v
  const chainId = Math.floor((v - 35) / 2);
  const yParity = (v - 35) % 2;
  const standardV = 27 + yParity;

  // Create new signature with standard v
  sig = Signature.fromSecp256k1(
    Signature.getR(sig),
    Signature.getS(sig),
    standardV
  );
}

Performance

Recovery Cost

Public key recovery is computationally expensive:
// Benchmark (approximate)
const sig = Signature.fromSecp256k1(r, s, 27);
const messageHash = Hash(keccak256(message));

console.time('recover');
const publicKey = Secp256k1.recoverPublicKey(sig, messageHash);
console.timeEnd('recover'); // ~0.5-1ms (native), ~2-5ms (WASM)

// Address recovery includes keccak256 hash
console.time('recoverAddress');
const address = Secp256k1.recoverAddress(sig, messageHash);
console.timeEnd('recoverAddress'); // ~0.6-1.2ms (native), ~2.5-6ms (WASM)

Optimization Strategies

Cache recovered addresses:
const recoveryCache = new Map<string, Address>();

function cachedRecover(sig: BrandedSignature, messageHash: Hash): Address {
  const key = Hex(Signature.toBytes(sig)) + Hex(messageHash);

  if (recoveryCache.has(key)) {
    return recoveryCache.get(key)!;
  }

  const address = Secp256k1.recoverAddress(sig, messageHash);
  recoveryCache.set(key, address);
  return address;
}
Batch recovery:
// Recover multiple signatures in parallel
async function recoverBatch(
  signatures: BrandedSignature[],
  messageHashes: Hash[]
): Promise<Address[]> {
  return Promise.all(
    signatures.map((sig, i) =>
      Secp256k1.recoverAddress(sig, messageHashes[i])
    )
  );
}

Algorithm Comparison

AlgorithmRecovery SupportUse Case
secp256k1✅ Yes (with v)Ethereum, Bitcoin
P-256❌ NoTLS, WebAuthn, JWT
Ed25519❌ NoModern protocols
Note: Only secp256k1 supports public key recovery. Other algorithms require storing the public key separately.

See Also