How to validate EIP-712 signatures

EIP-712 is a standard for secure off-chain signature verification on the Ethereum blockchain. Verifying EIP-712 signatures is not trivial, and we couldn’t find a great blog post about how to approach it using Golang, so I’m sharing my solution with the community.

What is EIP-712 signature verification and why should you use it?

EIP-712 is a standard for secure off-chain signature verification on the Ethereum blockchain. It provides a way to hash and sign typed structs rather than just strings. You might need to sign a struct if you’re building a decentralized application (dapp) that creates transactions with complex data that includes various types.
The purpose of signature verification is to ensure that the integrity of the message is upheld and that the message was in fact signed by the expected signer. A user might sign to approve a transaction, and it would be dangerous to carry out the transaction if it was approved by an unexpected address. Signature verification is the process of checking that the address of the signer is equal to the address that you derive from the signature.

Why is signature verification complicated?

Verifying signatures is not as straightforward as it may seem. To verify a signature, you must first reconstruct the message that was signed and then verify that the correct address signed it. Reconstructing this message is not trivial, and we couldn’t find a great blog post about how to approach it using Golang, so I’m sharing my solution with the community.

For signature verification, you need to know what message was signed, who signed the message, and how to duplicate the way the message was hashed. There are packages that help with hashing and verifying, but it’s important to know how and why to use each function so you can apply these methods on different types of data.

What are 0x NFT orders?

At Hook, we use EIP-712 to verify signatures on our NFT call option orders that are formed with 0x NFT order structs. The 0x (ZeroEx) Protocol is an open-source trading protocol written in Solidity that supports NFT orders, allowing Ethereum wallets to exchange ERC-20 tokens for ERC-721 NFTs. 0x developers were actually the ones who wrote the proposal for EIP-712, so it was designed with this particular use case in mind.

When a wallet places an NFT order, they, as the maker of the order (or a delegated registered allowed order signer), will have to sign the order with their private key for it to be fillable. To sign the order, you can use the EIP-712 standard. After the maker signs the order, the signature needs to be verified which entails checking that the wallet address derived from the order hash equals the original signer’s wallet address. The process of validating the order signature is unique for different types of orders and can therefore be rather confusing.

In this post, I’ll walk through how to verify signatures using the EIP-712 standard for hashing and signing typed structured data and will use 0x ERC-721 order structs to demonstrate the code. While some parts of this walkthrough may be specific to NFT orders, you can use the same methods to validate other types of messages once you understand how to format the data structures properly. The packages I mention are in Golang but there are similar ones in Javascript and Solidity, and you can use the open source code available online to transpose the logic to another language you would like to use.

EIP-712 Hashing and Signing

The EIP-712 standard was introduced to solve the problem that hashing data structs is more complex than hashing strings, as they include different types, and it’s important for people to know the contents of what they’re signing. Before this standard, wallet signing interfaces would display a hashed message string and the signer would have to assume that hash matched the message they thought they were signing. With EIP-712, wallets like Metamask can display the message in a more user-friendly manner so the signer can actually check the data before signing. Here’s what it looks like to sign a 0x order in Metamask–notice that each field is labeled and each value is easily readable.

Metamask transaction of EIP-712 signing

EIP-712 hashing and signing process

Verifying Order Signature

The process of verifying an order signature using EIP-712 is as follows:

  1. Hash the order data
  2. Recover the signer’s wallet address from the signature
  3. Check that the recovered wallet address matches the order’s makerAddress–if the addresses match, the signature is valid

Let’s walk through each of these steps in more detail.

Hashing a 0x Order

Typed Data

EIP-712 uses the keccak256 hashing algorithm to hash the typed order data. TypedData is the JSON object input that EIP-712 requires; it includes the following properties: types, domain, primaryType, and message. In Go Ethereum (Geth), the Golang interpretation of the Ethereum protocol, there’s a package called apitypes that has functions and types that can be helpful for doing this signature validation. I’ll use this package to demonstrate how the data should be formatted.

typedData := apitypes.TypedData{
Types:       types,
PrimaryType: "ERC721Order",
Domain:      domain,
Message:     message,
}

  1. Types: Types is a mapping of string to a Type array. This is used to define the structs that will be used in the message and specify their types. You can define new types here too, like how “Fee” and “Property” are types used in “ERC721Order”.

types := apitypes.Types{
"EIP712Domain": {
{Name: "name", Type: "string"},
{Name: "version", Type: "string"},
{Name: "chainId", Type: "uint256"},
{Name: "verifyingContract", Type: "address"},
},
"ERC721Order": {
{Name: "direction", Type: "uint8"},
{Name: "maker", Type: "address"},
{Name: "taker", Type: "address"},
{Name: "expiry", Type: "uint256"},
{Name: "nonce", Type: "uint256"},
{Name: "erc20Token", Type: "address"},
{Name: "erc20TokenAmount", Type: "uint256"},
{Name: "fees", Type: "Fee[]"},
{Name: "erc721Token", Type: "address"},
{Name: "erc721TokenId", Type: "uint256"},
{Name: "erc721TokenProperties", Type: "Property[]"},
},
"Fee": {
{Name: "recipient", Type: "address"},
{Name: "amount", Type: "uint256"},
{Name: "feeData", Type: "bytes"},
},
"Property": {
{Name: "propertyValidator", Type: "address"},
{Name: "propertyData", Type: "bytes"},
},
}

  1. PrimaryType: This is a string that represents the outermost type of the EIP712 message object but isn’t required to match any of the types in the message. In this example, the PrimaryType is “ERC721Order” because that’s what is specified in the TypedData Types.
  2. Domain: This TypedDataDomain struct details information specific to the protocol contract that the dapp used when asking for a signature.
  3. Name: For the 0x protocol you can use “ZeroEx” as the Name.
  4. Version: We’re using 0x’s version “1.0.0”.
  5. ChainId: The chain ID is the number that corresponds to the chain that the verifying contract is on, according to EIP-155–here is their list of chain IDs. If you are on Ethereum mainnet, the chain ID should be 1.
  6. VerifyingContract: The verifying contract should be the address of the protocol contract deployment on the correct chain. This is the list of 0x Contract Addresses–the exchange proxy address is the one to use here, so for Ethereum Mainnet the VerifyingContract would be “0xdef1c0ded9bec7f1a1670819833240f027b25eff”.

domain := apitypes.TypedDataDomain{
Name:    "ZeroEx",
Version: "1.0.0",
ChainId: 1,
VerifyingContract: "0xdef1c0ded9bec7f1a1670819833240f027b25eff",
}

  1. Message: The Message contains the order element names as strings mapped to their values. You can use TypedDataMessage to format the Message, making sure that each value type matches the type specified in the "ERC721Order" types. Most of the values can be set as they are, but there are a few that require pre-processing.

    Order Fees and Properties need to be properly structured and encoded before being passed into the Message.
  2. Fees: Order fees are amounts that the dapp adds to the order amount to send to the respective fee recipient addresses. Fees are a list of interfaces, which can be formed by making each element in the list a TypedDataMessage.
  3. feeData: The fee data on the Fee is optional, so if there is no feeData you can set it to an empty list of bytes. If there is feeData, you can pack the data into bytes using the Ethereum ABI Pack function.
  4. Properties: Order properties enable the use of property-based orders, so rather than specifying a specific token the maker can create a buy order (this is only applicable for buy orders) for any asset that meets the properties they specify. The Properties are also a list of TypedDataMessage interfaces, each with a propertyValidator and propertyData.
  5. PropertyValidator: The propertyValidator is the address of your propertyValidator contract that implements 0x’s IPropertyValidator interface. The propertyValidator contract is a custom contract that defines which properties you want to be able to filter for in property-based orders, and I will explain how to create one in another blog post. The address for the propertyValidator contract has to be the right one for the chain you are using.
  6. PropertyData: The propertyData is the ABI packed bytes of the properties of the order, with information about their specified types.

uint256Ty, _ := abi.NewType("uint256", "uint256", []abi.ArgumentMarshaling{})

tokenPropertyArguments := abi.Arguments{
abi.Argument{
Type: uint256Ty,
},
abi.Argument{
Type: uint256Ty,
},

}

propertyBytes, err := tokenPropertyArguments.Pack(
propertyA,
propertyB,
)

Property-based buy orders do not require a token ID. However, 0x automatically sets the token ID to zero on the client side when it asks for a signature. Therefore you will have to set the Order Message “erc721TokenId” to zero (“0”). The order will just ignore the token ID if there are valid erc721TokenProperties.

To hash the typed data, you can call HashStruct: a function provided by Geth that encodes the data then generates a keccak256 hash.

typedDataHash, err := typedData.HashStruct(typedData.PrimaryType, typedData.Message)

Domain Separator

The information from the domain also needs to be hashed and used as a domain separator. The purpose of the domain separator is to disambiguate between two dapps with identical structures in order to avoid generating the same signatures for both.

domainSeparator, err := typedData.HashStruct("EIP712Domain", typedData.Domain.Map())

Once you hash the domain, concatenate the domainSeparator with the typedDataHash and hash it again using keccak256 to create the final data that was signed by the maker’s wallet.

rawData := []byte(fmt.Sprintf("\x19\x01%s%s", string(domainSeparator), string(typedDataHash)))
hashBytes := keccak256(rawData)
hash := common.BytesToHash(hashBytes)

Now that you’ve successfully recreated the original signed order hash, we can move on to decoding the order signature.

Recovering the Signer’s Wallet Address from a Signature

How does Ecreover work?

The Geth crypto package has a function called Ecrecover that takes in a hash (here it’s the order hash) and a signature (in bytes) and returns the public key of the Ethereum account that signed the hashed message.
Ecrecover uses the Elliptic Curve Digital Signature Algorithm (ECDSA), the secure cryptographic method for message signing. ECDSA uses an elliptic curve to bounce points off of a large number of times to reach a final point, which makes it a computationally intensive logarithmic problem that is hard to break.

elliptic curve, source: https://yos.io/2018/11/16/ethereum-signatures/#ecdsa

You already have the order hash, but before you can use Ecrecover, you’ll need to get the signature in bytes.

How to convert a signature to bytes

This blog post explains more about Ethereum signatures, but it’s important to know that the 65 byte signature is made up of three parts: {r, s, v} where r and s are 32 bytes each and v is one byte. The last byte, v (or the recovery ID), is set to ({0,1} + 27). In Ecrecover, the recovery ID is expected to be either 0 or 1, so you can subtract 27 from that byte.

There are a few checks you have to make to ensure that the signature is valid:

  1. Hex decode the signature string into bytes
  2. Check that the length of the signature is 65 bytes
  3. Make sure that the last byte of the signature is equal to 0 or 1

signature = signature[2:] // ignores "0x" at beginning of string
signatureBytes, err := hex.DecodeString(signature)

if len(signatureBytes) != 65 {
return false, fmt.Errorf("invalid signature length: %d", len(signatureBytes))
}
// check the value of the v part of the signature bytes;
// Ecrecover expects v to be either 0 or 1, so if the 27 is added,
// you should subtract 27 from v
if signatureBytes[64] == 27 || signatureBytes[64] == 28 {
signatureBytes[64] -= 27
}

How to convert signature bytes to an Ethereum address

You can pass the order hash bytes and signature bytes into Ecrecover to get the public key (in bytes) of the wallet that signed the order. To convert the public key bytes into a secp256k1 public key, you need to unmarshal it using UnmarshalPubkey. Then it can be converted into an address using PubkeyToAddress.

pubKeyBytes, err := crypto.Ecrecover(orderHash[:], signatureBytes)
if err != nil {
return false, fmt.Errorf("invalid signature: %s", err.Error())
}

pubKey, err := crypto.UnmarshalPubkey(pubKeyBytes)
if err != nil {
return false, fmt.Errorf("cannot unmarshal public key: %s", err.Error())
}

recoveredAddr := crypto.PubkeyToAddress(*pubKey)

Checking Signer’s Wallet Address Against Expected Address

All that’s left is to compare the order maker’s address bytes and the recovered address bytes to check if they’re equal.

if !bytes.Equal(makerAddr.Bytes(), recoveredAddr.Bytes()) {
return false, errors.New("addresses do not match")
}

If the recovered address matches the expected address, then the message is valid and you can continue with the transaction. Otherwise, it’s possible that someone attempted to place a malicious transaction and you are able to prevent it from going through.

EIP-712 makes Ethereum signing substantially safer and protects the users of wallets and decentralized applications. By using this method, you can deter bad actors from exploiting your users. Hopefully you can use this blog post as a guide to implement EIP-712 signing and signature verification in your dapp to create a better and more secure experience.

Read More