Nima Labs Logo

The Usage of EIP2098 - Digital Signature Compact Representation

By Marko Lazic - Tuesday, December 23, 2025

Blog Image

Introduction

In the following article, we will explain the benefits of EIP2098 — signature compact representation — usage in blockchain technologies and present the technical attributes of signatures that make the formatting possible.

This article primarily focuses on the Ethereum (EVM) environment, but the combination of the ECDSA with the Secp256k1 elliptic curve is often used in other blockchain technologies as well (ex. in Bitcoin), as it is a combination that enables concise, secure, and computationally inexpensive digital signatures. Therefore, the application of EIP2098 goes beyond the EVM environment.

Someone may ask: “Why do Ethereum and Bitcoin signatures look different if they both use the same cryptographic algorithm and elliptic curve?”, this is due to the choice of encoding algorithm — Ethereum uses the RLP algorithm, and Bitcoin uses DER.

We will widely elaborate on this topic in order to achieve deep understanding of how the signature formatting works.

The Compact Representation | EIP2098 — Walkthrough

High-level Walkthrough

The difference between regular and compact signature representation can be observed through the ways in which signatures are defined inside a smart contract written in Solidity using different data types and structures in the following examples:

  1. A single dynamic parameter
    Occupies 160|128 bytes — bytes calldata|memory signature
    96 bytes for the signature (65 non-zero bytes + 31 zero bytes) and 64 (calldata) or 32 (memory) bytes for dynamic type metadata.
  2. Split into three static parameters
    Occupies 96 bytes — uint8 v, bytes32 r, bytes32 s
    Although the uint8 type is 1 byte in size, within the calldata|memory or on stack, it occupies 32 bytes, i.e. one entire memory word/stack slot.
  3. Reduced to two parameters (compact representation)
    Occupies 64 bytes — bytes32 r, bytes32 vs
    Values are being pushed to stack in an optimal way.

Using the second method instead of the first one already represents a significant optimization — one should resort to static data types whenever possible; however, the third method represents the most optimal approach.

This approach is feasible because the value v, also known as yParity or recovery value/id, requires only a single bit for storage.

Understanding the Benefits of Formatting

Metadata in calldata|memory: Inside the calldata, every dynamic type needs to have an offset which points to the starting word of the value and the value length as its metadata, in separate 32 byte words, which makes for 64 bytes (2 words) of metadata in total. In memory, only value length is needed, which makes for 32 bytes (1 word).

Byte pricing in calldata|memory|stack: Zero bytes inside the calldata cost 4 gas each, while the non-zero bytes cost 16 gas each. MSTORE opcode (memory store) has a dynamic cost of 3 static gas + memory expansion cost. Pushing a value to stack costs 3 gas fixed, only recently PUSH0 opcode was introduced, enabling an empty push for a price of 2 gas.

Packing outside of storage: When working with calldata|memory or with stack, in standard conditions, packing does not occur by default. So the types, no matter how small, will usually take an entire memory word|calldata word|stack slot.

Peek at the Elliptic Curve and Finite Field

An elliptic curve projected over a finite field is the foundation for the mathematical operations that make public-key cryptography possible. Signatures’ security features come from the inability to reverse the signature computation process, which is achieved through the usage of modulo (which is embedded in the concept of the finite field) and very large numbers. On the contrary, this system keeps signature creation and verification computationally lightweight.

Blog Image

Figure 1.1 — Elliptic Curve ‘37a’ Projected onto Real Number Infinite Field (Generated using SageMath)

Blog Image

Figure 1.2 — Elliptic Curve ‘37a’ Projected onto a Finite Field (mod 500) (Generated using SageMath)

Dive into Signature Formatting — Values V and S

Almost every x coordinate in the elliptic curve has multiple corresponding y coordinates (Figures 1.1 and 1.2), and the value of v defines which of them corresponds to our selected random point (which was chosen for the purpose of creating the signature). If the aforementioned value is not present in the signature, it is still possible to verify the signature without it — by trying all potentially corresponding y coordinates (in almost 100% of cases, we will only have two options, 0 and 1).

From this we can conclude that the value v serves to optimize the process and is not a mandatory element of the signature in the broader sense.
Nevertheless, it is still kept within the compact representation.

The first bit of the value s is always 0, because it initially represents the sign of the integer value (+ or -). Since the entered value is always converted to a positive (within the canonical digital signature operations), and since the value of v will ~never go above 1, within the compact representation, we can comfortably place the value v in that first bit.

Using simple bitwise operations, it is possible to join together the parameters v and s, as well as extract v and s from vs:

  • Joining v and s into vs: (v << 255) | s
  • Extracting v from vs: (vs >> 255)
  • Extracting s from vs: vs & ((1 << 255) - 1)

Practical Example

Blog Image

Figure 2 — Examples of Digital Signatures with Different Values ​​of V and Visible Change in VS

By taking a closer look at the Figure 2, we can notice that the first byte of a hexadecimal string s, in the first case, is not changed in vs — this is due to value of v being 0 (s | 0 => s).

The second case is different, as the value of v is now 1, and therefore we notice the difference between the first byte of s and of vs.

Since the hexadecimal representation of a byte string presents each nibble as a character from 0 to f, only the first nibble is changed.

Since the first bit is always empty in s, maximum value of the first nibble is 0111 in binary, which corresponds to 0x7 in hex, while the maximum value of the first byte as a whole is 01111111 in binary which corresponds to 0x7f in hex.

In the second case, the first byte of s has a hexadecimal value of 0x3b, which translates to 00111011 in binary. We will now follow through the previously mentioned bitwise method to merge v and s.

v = 0b1 i.e. 0b000…001255 zeroes followed by 1
v << 2550b100…0001 followed by 255 zeroes

1v = 0x8000000000000000000000000000000000000000000000000000000000000000
2s = 0×3b13b3a6a7e9ac12b88b5bdf29e8189c339d9fc5eeac548e3792477aa74cd67f
3v | s => vs
4vs = 0×bb13b3a6a7e9ac12b88b5bdf29e8189c339d9fc5eeac548e3792477aa74cd67f

Let's take a closer look at how the `or` operation changed the first byte:
00111011 | 10000000 => 10111011 i.e. 0x3b | 0x80 => 0xbb

Summary:
Case 1 → Since v is 0vs and s are one and the same.
Case 2 → Since v is 1, first nibble of s is changed in vs.

In-code Demonstration

The block below shows the script written in TypeScript that allows you to easily create a digital signature and join together the values of ​​v and s into vs. It can be used to generate different examples, or as a starting point for doing different operations on signatures — such as verification.

Upon execution, make sure to properly inspect the values v, s and vs.

javascript
1import { secp256k1 } from '@noble/curves/secp256k1.js';
2import { keccak_256 } from '@noble/hashes/sha3.js';
3
4const uint8ArrayToHex = (arr: Uint8Array): string => {
5    return Array.from(arr).map(byte => byte.toString(16).padStart(2, '0')).join('');
6}
7
8// Create a new private key and a message hash to sign
9const pk = secp256k1.utils.randomSecretKey();
10const msgHash = keccak_256(Buffer.from("Hello :)"));
11
12// Retrieve the signer address from private key
13const uncompressedPublicKey = secp256k1.getPublicKey(pk, false);
14const signerAddress = '0x' + uint8ArrayToHex(keccak_256(uncompressedPublicKey.subarray(1))).substring(24);
15
16// Sign message / retrieve the signature object
17const signatureBytes = secp256k1.sign(msgHash, pk, {prehash: false, format: 'recovered'})
18const signature = secp256k1.Signature.fromBytes(signatureBytes, 'recovered');
19
20// Retrieve the values of `s` and `v` from signature object
21const s = signature.s.toString(16).padStart(64, "0");
22const v = signature.recovery;
23
24// Retrieve the first byte of `s`
25let firstByte = parseInt(s.substring(0, 2), 16);
26// If `v` has the value of 1, set 1 as the value of the first bit of `vs`
27if (v === 1) firstByte |= 0x80;
28
29// Create `vs` by concatenating the updated first byte with the rest of the value `s`
30const vs = (firstByte.toString(16) + s.substring(2)).padStart(64, "0");
31
32// Log `r`, `s`, `v` and `vs` - if `v` == 0, `vs` and `s` will match completely
33console.log({
34    signerAddress, 
35    messageHash: `0x${uint8ArrayToHex(msgHash)}`,
36    r: `0x${signature.r.toString(16).padStart(64, "0")}`,
37    s: `0x${s}`,
38    v,
39    vs: `0x${vs}`,
40    verified: secp256k1.verify(signatureBytes, msgHash, uncompressedPublicKey,{prehash: false, format: 'recovered'})
41});

The following packages are needed:

1{
2  "@noble/curves": "2.0.1",
3  "@noble/hashes": "2.0.1"
4}

A log example:

1{
2signerAddress: '0x90f0d4f72f27b245563f742bced5f24216775d2a',
3messageHash: '0x22debc88ada10e4de96107d781a4e8bfd8bd7ac9452b15213e38258cb4fb15b7',
4r: '0x64dd0321e356b5d2b8a3977f0d0fec96d1dcd04db91c6778293a43cc5b21e3f6',
5s: '0x12a80a506978f21de012000b952a71b5ee6321d3f3d0c495e5b035bce4f2b038',
6v:1,
7vs: '0x92a80a506978f21de012000b952a71b5ee6321d3f3d0c495e5b035bce4f2b038',
8verified:true
9}

On-chain Verification of a Compactly Represented Signature (EVM)

Like signatures represented in other ways (which are more commonly seen), compactly represented signatures can be verified using the OpenZeppelin ECDSA library - which can be easily used within a Solidity smart-contract.

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.20;
3
4import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v5.0.0/contracts/utils/cryptography/ECDSA.sol";
5
6contract SignatureVerifier {
7    // @dev This function will check if the `hash` is signed by the passed signer
8    function verifySignature(address signer, bytes32 hash, bytes32 r, bytes32 vs) external pure returns (bool) {
9        return signer == ECDSA.recover(hash, r, vs);
10    }
11}

The simplest way to test out the SignatureVerifier smart-contract is through the Remix IDE. Make sure to use some of the previously generated examples.

EIP2098 Closing Remarks

EIP2098 is a simple and easy-to-use signature formatting technique that brings numerous benefits to the on-chain signature workflows.

Depending on your smart-contract implementation, it can be seen as a nice finishing touch or a much-needed solution to the execution complexity.

We appreciate you reading through this article and honestly hope it helped you learn something new. See you in the next one!

Written by Marko Lazic.

Check out our current openings here.

Relevant Resources

Back to Blogs

Share this post with friends