Back to blogs
Written by
Ciara Nightingale
Published on
April 23, 2024

EIP712 and EIP191 | Understanding Ethereum Signature Standards

Learn everything you need to know about the (Ethereum improvement proposal) EIP91, EIP712, and Ethereum signature standards.

Table of Contents

To understand how signature creation, verification, and preventing replay attacks work, the Ethereum Improvement Proposals EIP-191 and EIP-712 need to be understood first.

When signing transactions, there needed to be an easier way to read transaction data. For example, before these standards were created, the following message was displayed when signing a transaction in MetaMask:

Image showing metamask signature message before eip712 was implemented

Image: Unstructured message before EIP712 when signing transaction in MetaMask

These standards meant that transactions could be displayed in a readable way:

Image showing metamask transaction data after eip 712 was implemented

Image: Structured message using EIP-712 when signing transaction in MetaMask

Additionally, EIP-712 is key to preventing replay attacks - the data to prevent replay attacks is encoded inside the structured data.

This article outlines these standards, their motivations, and how to implement them.

— The full source code for this article was written by Patrick Collins and can be viewed on GitHub.

Prerequisites to understand the EIP712 and EIP191

This ECDSA Signatures article is recommended for understanding the fundamentals of the first two concepts.

— Note that the code in this article is for demonstrative purposes and has not undergone a thorough security review, do not use it as production code.

Simple Signatures

For simple signatures, implementing a verification function into a smart contract involves creating a function getSimpleSigner() which takes a message to sign (which can be any data) and the (r, s, v) components of the signature, The function hashes the message and retrieves the signer, using the precompile ecrecover, and returns the result:

function getSignerSimple(uint256 message, uint8 _v, bytes32 _r, bytes32 _s) public pure returns (address) {
	// hash the message to sign
	bytes32 hashedMessage = bytes32(message); // if string, use keccak256(abi.encodePacked(string))
	// retrieve the signer
	address signer = ecrecover(hashedMessage, _v, _r, _s);
	return signer;
}

ecrecover is a precompile, a function that is built into the Ethereum protocol, that retrieves the signer from any message using the (r, s, v) components of the signature.

Then, the function verifySignerSimple() compares the retrieved signer to an expected signer and reverts if the result is not the expected signer:

function verifySignerSimple(
	uint256 message,
	uint8 _v,
	bytes32 _r,
	bytes32 _s,
	address signer
)
	public
	pure
	returns (bool)
{
	address actualSigner = getSignerSimple(message, _v, _r, _s);
	require(signer == actualSigner);
	return true;
}

This is how signatures work on a fundamental level: take some hashed message plus the signature of the message, retrieve the signer, and check that it’s the address that was expected.

There was an issue with this though, there needed to be a way to send transactions using pre-made signatures: sponsored transactions. This was already possible outside of smart contracts however there needed to be a way to build this into functions in smart contracts. For example, Bob signs a message (a transaction) and gives the signature to Alice. Alice uses this signature to send the transaction meaning that Bob can pay for her gas fees. So, EIP-191 was introduced

Ethereum Improvement Proposal - EIP191: Standardizing Signatures

The EIP-191 Signed Data Standard proposed the following format for signed data: 0x19 <1 byte version> <version specific data> <data to sign>

  • 0x19: The prefix.
  • This signifies that the data is a signature.
  • Its decimal value is 25 0x19 was chosen because it is not used in any other context.
  • It also ensures that the data associated with the signed message cannot be a valid ETH transaction due to how ETH transactions are encoded.
  • <1 byte version>: The version of “signed data” is used.
  • Allows different versions to have different signed data structures.
  • Values:
  • 0x00: Data with the intended validator.
  • 0x01: Structures data - most often used in production apps and associated with EIP-712, discussed in the next section.
  • 0x02: personal_sign messages.
  • <data to sign>: The message intended to be signed.

The following getSigner191() function demonstrates how to set up an EIP-191 signature:

function getSigner191(uint256 message, uint8 _v, bytes32 _r, bytes32 _s) public view returns (address) {
        // Arguments when calculating hash to validate
        // 1: byte(0x19) - the initial 0x19 byte
        // 2: byte(0) - the version byte
        // 3: version specific data, for version 0, it's the intended validator address
        // 4-6 : Application specific data

        bytes1 prefix = bytes1(0x19);
        bytes1 eip191Version = bytes1(0);
        address indendedValidatorAddress = address(this);
        bytes32 applicationSpecificData = bytes32(message);

        // 0x19  < 1 byte version>  < version specific data>  < data to sign>
        bytes32 hashedMessage =
            keccak256(abi.encodePacked(prefix, eip191Version, indendedValidatorAddress, applicationSpecificData));

        address signer = ecrecover(hashedMessage, _v, _r, _s);
        return signer;
    }

As observed, retrieving the signer is more verbose using this standard.

The signer can then be compared with the expected signer as before:

function verifySigner191(
	uint256 message,
	uint8 _v,
	bytes32 _r,
	bytes32 _s,
	address signer
)
	public
	view
	returns (bool)
{
	address actualSigner = getSigner191(message, _v, _r, _s);
	require(signer == actualSigner);
	return true;
}

However, what if the <data to sign> is more complicated? There needed to be a way to format the data so that it could be more easily understood. Therefore, the data format needed to be standardized, and EIP-712 was introduced.

Ethereum Improvement Proposal - EIP712: Making Signatures Readable

EIP-191 was not specific enough and the application (version) specific data needed to be standardized. This meant that signatures could be easier to read and displayed from inside wallets e.g. MetaMask and prevents replay attacks.

EIP-712 introduced standardized data: typed structured data hashing and signing.

The signature now has the following structure:

0x19 0x01 <domainSeparator> <hashStruct(message)>

  • 0x19: The prefix (from before)
  • 0x01: the version
  • <domainSeparator>: This is the data associated with the version.
  • A domain separator is the hash of a struct defining the domain of the message being signed.
  • The struct contains one of all of the following and is known as the eip712Domain:

struct EIP712Domain {	
	string name;
	string version;
	uint256 chainId;
	address verifyingContract;
	// bytes32 salt; not required
}

This means that contracts can know whether the signature was created specifically for themselves or not. Knowing this, the EIP-712 data can be rewritten as:

0x19 0x01 <hashStruct(eip712Domain)> <hashStruct(message)>

But, what is a hash struct?

The symbolic definition of a hash struct is

hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s))

where typeHash = keccak256(encodeType(typeOf(s)))

A hash struct is a hash of a struct and includes:

hash of what the struct looks like - the typehash. A typehash is a hash of the struct (the type). For the <domainSeparator> the typehash is:

// The hash of the EIP721 domain struct
bytes32 constant EIP712DOMAIN_TYPEHASH =
	keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");

A hash of the data. For the domain separator, that data is the eip721Domain struct data:

// Define what the "domain" struct looks like.
EIP712Domain eip_712_domain_separator_struct = EIP712Domain({
	name: "SignatureVerifier", // this can be anything
	version: "1", // this can be anything
	chainId: 1, // ideally the chainId
	verifyingContract: address(this) // ideally, set this as "this", but can be any contract to verify signatures
});

Putting this together, the <domainSeparator> becomes:

// Now the format of the signatures is known, define who is going to verify the signatures.
bytes32 public immutable i_domain_separator = keccak256(
	abi.encode(
		EIP712DOMAIN_TYPEHASH,
		keccak256(bytes(eip_712_domain_separator_struct.name)),
		keccak256(bytes(eip_712_domain_separator_struct.version)),
		eip_712_domain_separator_struct.chainId,
		eip_712_domain_separator_struct.verifyingContract
	)
);

  • <hashStruct(message)>: a hash struct of the message to sign.

Using the previous definition of a hash struct, define the typehash:

// define what the message hash struct looks like.
struct Message {
	uint256 number;
}

bytes32 public constant MESSAGE_TYPEHASH = keccak256("Message(uint256 number)");

Then, <hashStruct(message)> becomes:

bytes32 hashedMessage = keccak256(abi.encode(MESSAGE_TYPEHASH, Message({ number: message })));

The EIP-712 data can be thought of as:

0x19 0x01 <hash of who verifies this signature, and what the verifier looks like> < hash of signed structured message, and what the signature looks like>

Putting this all together, the get signer function becomes:

function getSignerEIP712(uint256 message, uint8 _v, bytes32 _r, bytes32 _s) public view returns (address) {
	// Arguments when calculating hash to validate
	// 1: byte(0x19) - the initial 0x19 byte
	// 2: byte(1) - the version byte
	// 3: hashstruct of domain separator (includes the typehash of the domain struct)
	// 4: hashstruct of message (includes the typehash of the message struct)

	bytes1 prefix = bytes1(0x19);
	bytes1 eip712Version = bytes1(0x01); // EIP-712 is version 1 of EIP-191
	bytes32 hashStructOfDomainSeparator = i_domain_separator;

	// hash the message struct
	bytes32 hashedMessage = keccak256(abi.encode(MESSAGE_TYPEHASH, Message({ number: message })));

	// And finally, combine them all
	bytes32 digest = keccak256(abi.encodePacked(prefix, eip712Version, hashStructOfDomainSeparator, hashedMessage));
	return ecrecover(digest, _v, _r, _s);
}

The signer can then be verified by comparing it to the expected signer as before:

function verifySigner712(
	uint256 message,
	uint8 _v,
	bytes32 _r,
	bytes32 _s,
	address signer
)
	public
	view
	returns (bool)
{
	address actualSigner = getSignerEIP712(message, _v, _r, _s);

	require(signer == actualSigner);
	return true;
}

Signature Replay Attack Prevention

As mentioned earlier, EIP712 is key to preventing replay attacks.

understanding EIP-191 and EIP-712 is important for understanding how to create replay-resistant data to sign into a signature. The extra data in the structure of EIP-712 ensures replay resistance.

To prevent replay attacks, smart contracts must:

  1. Have every signature have a unique nonce that is validated
  2. Set and check an expiration date
  3. Restrict the s value to a single half
  4. Include a chain ID to prevent cross-chain replay attacks
  5. Any other unique identifiers (for example, if there are multiple objects to sign in the same contract/chain/etc)

— For more information on signature replay attacks and how to prevent them, refer to this comprehensive guide.

Summary

In this guide about EIP-191 and EIP-712, you've learned how Ethereum signature standards work. To summarize:

  • EIP-191: standardizes what signed data should look like.
  • EIP-712: standardizes the format of the version-specific data and the data to sign.

In order to fully understand signature creation, verification, and signature replay, it is imperative to understand these two standards. This understanding is the key to writing secure smart contracts.

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.