Skip to main content

Try it Live

Run Signature examples in the interactive playground

EIP-2098: Compact Signature Representation

Space-efficient signature format that embeds the recovery ID in the s component.

Overview

EIP-2098 defines a compact 64-byte signature format that includes recovery information by utilizing the highest bit of the s component. This reduces signature size from 65 bytes to 64 bytes while preserving recovery capability. Benefits:
  • 🔹 64 bytes instead of 65 bytes (1.5% space savings)
  • 🔹 Preserves recovery functionality
  • 🔹 Compatible with existing ECDSA signatures
  • 🔹 Reduces gas costs in smart contracts

Format Structure

Standard (65 bytes)

[0-31]   r (32 bytes)
[32-63]  s (32 bytes)
[64]     v (1 byte: 27 or 28)

EIP-2098 Compact (64 bytes)

[0-31]   r (32 bytes)
[32-63]  s' (32 bytes with embedded yParity)

where: s'[0] = s[0] | (yParity << 7)
       yParity = v - 27 (either 0 or 1)

Encoding

Compact Encoding Process

import { Signature } from 'tevm';

function toEIP2098(sig: BrandedSignature): Uint8Array {
  // Get components
  const r = Signature.getR(sig);
  const s = Signature.getS(sig);
  const v = Signature.getV(sig);

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

  // Calculate yParity (0 or 1)
  const yParity = v === 27 ? 0 : 1;

  // Create compact signature
  const compact = Bytes64();
  compact.set(r, 0);
  compact.set(s, 32);

  // Embed yParity in highest bit of s
  compact[32] |= (yParity << 7);

  return compact;
}

// Example
const sig = Signature.fromSecp256k1(r, s, 27);
const compact = toEIP2098(sig);
console.log(compact.length); // 64 bytes

Decoding Process

function fromEIP2098(compact: Uint8Array): BrandedSignature {
  if (compact.length !== 64) {
    throw new Error('EIP-2098 signature must be 64 bytes');
  }

  // Extract r
  const r = compact.slice(0, 32);

  // Extract s and yParity
  const sWithParity = compact.slice(32, 64);
  const yParity = sWithParity[0] >> 7; // Extract high bit

  // Clear high bit to get s
  const s = new Uint8Array(sWithParity);
  s[0] &= 0x7f; // Clear highest bit

  // Calculate v
  const v = 27 + yParity;

  return Signature.fromSecp256k1(r, s, v);
}

// Example
const compact = Bytes64(); // From contract or API
const sig = fromEIP2098(compact);
console.log(Signature.getV(sig)); // 27 or 28

Validation

Check if Signature is EIP-2098 Compact

function isEIP2098(bytes: Uint8Array): boolean {
  // Must be exactly 64 bytes
  if (bytes.length !== 64) {
    return false;
  }

  // Check if highest bit of s is set (indicates embedded yParity)
  // Note: This is a heuristic - not definitive
  const sFirstByte = bytes[32];
  return (sFirstByte & 0x80) !== 0;
}

Canonical Form Requirement

EIP-2098 signatures MUST be canonical (low-s form) because the high bit of s is used for yParity:
function validateEIP2098(compact: Uint8Array): boolean {
  const sig = fromEIP2098(compact);

  // Must be canonical (s ≤ n/2)
  if (!Signature.isCanonical(sig)) {
    throw new Error('EIP-2098 requires canonical signatures');
  }

  return true;
}

Smart Contract Usage

Solidity Example

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract EIP2098Verifier {
    /**
     * @dev Verify EIP-2098 compact signature
     * @param hash Message hash
     * @param compactSig 64-byte compact signature (r + s with embedded yParity)
     * @return signer Recovered address
     */
    function verifyCompact(
        bytes32 hash,
        bytes memory compactSig
    ) public pure returns (address) {
        require(compactSig.length == 64, "Invalid signature length");

        bytes32 r;
        bytes32 s;
        uint8 v;

        // Extract r (first 32 bytes)
        assembly {
            r := mload(add(compactSig, 32))
        }

        // Extract s and yParity (last 32 bytes)
        bytes32 sWithParity;
        assembly {
            sWithParity := mload(add(compactSig, 64))
        }

        // Extract yParity from highest bit
        v = uint8(sWithParity[0] >> 7) + 27;

        // Clear highest bit to get s
        s = bytes32(uint256(sWithParity) & 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff);

        // Recover signer
        return ecrecover(hash, v, r, s);
    }

    /**
     * @dev Convert standard signature to EIP-2098
     * @param r Signature r component
     * @param s Signature s component
     * @param v Recovery ID (27 or 28)
     * @return compact 64-byte compact signature
     */
    function toCompact(
        bytes32 r,
        bytes32 s,
        uint8 v
    ) public pure returns (bytes memory) {
        require(v == 27 || v == 28, "Invalid v");

        // Calculate yParity
        uint8 yParity = v - 27;

        // Embed yParity in highest bit of s
        bytes32 sWithParity = bytes32(uint256(s) | (uint256(yParity) << 255));

        // Concatenate r and s
        return abi.encodePacked(r, sWithParity);
    }
}

Gas Savings

// Standard signature (65 bytes)
function verifyStandard(
    bytes32 hash,
    bytes memory signature
) public pure returns (address) {
    require(signature.length == 65);

    bytes32 r;
    bytes32 s;
    uint8 v;

    assembly {
        r := mload(add(signature, 32))
        s := mload(add(signature, 64))
        v := byte(0, mload(add(signature, 96)))
    }

    return ecrecover(hash, v, r, s);
}
// Calldata cost: 65 bytes * 16 gas/byte = 1,040 gas

// EIP-2098 compact (64 bytes)
function verifyCompact(
    bytes32 hash,
    bytes memory compactSig
) public pure returns (address) {
    // ... (as shown above)
}
// Calldata cost: 64 bytes * 16 gas/byte = 1,024 gas
// Savings: 16 gas per signature

Conversion Utilities

Bidirectional Conversion

class EIP2098 {
  /**
   * Convert standard signature to EIP-2098 compact
   */
  static toCompact(sig: BrandedSignature): Uint8Array {
    const r = Signature.getR(sig);
    const s = Signature.getS(sig);
    const v = Signature.getV(sig);

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

    // Ensure canonical (required for EIP-2098)
    if (!Signature.isCanonical(sig)) {
      sig = Signature.normalize(sig);
    }

    const yParity = v === 27 ? 0 : 1;
    const compact = Bytes64();

    compact.set(r, 0);
    compact.set(s, 32);
    compact[32] |= (yParity << 7);

    return compact;
  }

  /**
   * Convert EIP-2098 compact to standard signature
   */
  static fromCompact(compact: Uint8Array): BrandedSignature {
    if (compact.length !== 64) {
      throw new Error('EIP-2098 must be 64 bytes');
    }

    const r = compact.slice(0, 32);
    const sWithParity = compact.slice(32, 64);

    // Extract yParity and s
    const yParity = sWithParity[0] >> 7;
    const s = new Uint8Array(sWithParity);
    s[0] &= 0x7f;

    const v = 27 + yParity;

    return Signature.fromSecp256k1(r, s, v);
  }

  /**
   * Check if bytes are valid EIP-2098
   */
  static isValid(bytes: Uint8Array): boolean {
    if (bytes.length !== 64) return false;

    try {
      const sig = this.fromCompact(bytes);
      return Signature.isCanonical(sig);
    } catch {
      return false;
    }
  }
}

Real-World Examples

MetaMask Personal Sign

// User signs message with MetaMask
const message = 'Sign this message';
const signature = await ethereum.request({
  method: 'personal_sign',
  params: [message, account]
});

// Parse signature (65 bytes: r + s + v)
const sig = Signature(Hex.toBytes(signature));

// Convert to EIP-2098 compact
const compact = EIP2098.toCompact(sig);

// Submit to contract (saves gas)
await contract.verifyCompact(messageHash, compact);

API Response

// API returns compact signature
const response = await fetch('/api/sign', {
  method: 'POST',
  body: JSON.stringify({ message })
});

const { compactSignature } = await response.json();

// Parse EIP-2098 compact
const compact = Hex.toBytes(compactSignature);
const sig = EIP2098.fromCompact(compact);

// Use standard signature API
const signer = Secp256k1.recoverAddress(sig, messageHash);

Batch Verification

// Verify multiple EIP-2098 signatures efficiently
async function verifyBatch(
  messages: Uint8Array[],
  compactSignatures: Uint8Array[]
): Promise<Address[]> {
  return Promise.all(
    messages.map(async (msg, i) => {
      const hash = keccak256(msg);
      const sig = EIP2098.fromCompact(compactSignatures[i]);
      return Secp256k1.recoverAddress(sig, hash);
    })
  );
}

// Example
const signatures = [
  Hex.toBytes('0x1234...'), // 64 bytes each
  Hex.toBytes('0x5678...'),
  Hex.toBytes('0x9abc...')
];

const signers = await verifyBatch(messages, signatures);

Compatibility

EIP-155 Chain ID Encoding

EIP-2098 uses yParity (0 or 1) instead of full v value. For EIP-155 signatures:
function eip155ToCompact(
  r: Uint8Array,
  s: Uint8Array,
  v: number, // EIP-155 encoded: chainId * 2 + 35 + yParity
  chainId: number
): Uint8Array {
  // Extract yParity from EIP-155 v
  const yParity = (v - 35 - chainId * 2) % 2;

  // Create standard v
  const standardV = 27 + yParity;

  // Create signature
  const sig = Signature.fromSecp256k1(r, s, standardV);

  // Convert to EIP-2098
  return EIP2098.toCompact(sig);
}

// Example: Mainnet (chainId = 1)
const v = 37; // EIP-155 encoded (yParity = 0)
const compact = eip155ToCompact(r, s, v, 1);

Supporting Multiple Formats

// Support both 64-byte and 65-byte formats
function parseSignature(bytes: Uint8Array): BrandedSignature {
  if (bytes.length === 64) {
    // EIP-2098 compact
    return EIP2098.fromCompact(bytes);
  } else if (bytes.length === 65) {
    // Standard compact with v
    return Signature.fromCompact(bytes, 'secp256k1');
  } else {
    throw new Error('Invalid signature length');
  }
}

Security Considerations

Canonical Requirement

// EIP-2098 requires canonical signatures
const sig = Signature.fromSecp256k1(r, sHigh, 27);

if (!Signature.isCanonical(sig)) {
  // MUST normalize before converting to EIP-2098
  sig = Signature.normalize(sig);
}

const compact = EIP2098.toCompact(sig);
Why? The highest bit of s is used for yParity. Non-canonical signatures have high s values that would conflict.

High Bit Validation

// Validate s doesn't have high bit set naturally
function validateSValue(s: Uint8Array): boolean {
  // First byte should be < 0x80 for canonical signatures
  return s[0] < 0x80;
}

const s = Signature.getS(sig);
if (!validateSValue(s)) {
  throw new Error('Signature not canonical - cannot use EIP-2098');
}

Malleability Protection

// Always normalize before EIP-2098 encoding
function safeToEIP2098(sig: BrandedSignature): Uint8Array {
  // Normalize to prevent malleability
  const canonical = Signature.normalize(sig);

  // Verify canonical
  if (!Signature.isCanonical(canonical)) {
    throw new Error('Failed to normalize signature');
  }

  return EIP2098.toCompact(canonical);
}

Performance

Space Savings

// Standard signature
const standard = new Uint8Array(65); // r + s + v
console.log(standard.length); // 65 bytes

// EIP-2098 compact
const compact = Bytes64(); // r + s'
console.log(compact.length); // 64 bytes

// Savings
console.log((1 - 64/65) * 100); // 1.54% space reduction

Gas Savings (Ethereum)

// Calldata gas cost
const CALLDATA_BYTE_COST = 16; // Non-zero byte

// Standard (65 bytes)
const standardGas = 65 * CALLDATA_BYTE_COST; // 1,040 gas

// EIP-2098 (64 bytes)
const compactGas = 64 * CALLDATA_BYTE_COST; // 1,024 gas

// Savings
console.log(standardGas - compactGas); // 16 gas per signature

Encoding Performance

// Benchmark (approximate)
const sig = Signature.fromSecp256k1(r, s, 27);

// To EIP-2098
console.time('toEIP2098');
const compact = EIP2098.toCompact(sig);
console.timeEnd('toEIP2098'); // ~0.001-0.002ms

// From EIP-2098
console.time('fromEIP2098');
const decoded = EIP2098.fromCompact(compact);
console.timeEnd('fromEIP2098'); // ~0.001-0.002ms

See Also