EIP-712 and Gasless Transactions

Imagine you have a shiny new startup that lets your customers pay via ERC20 tokens. Now for each purchase, the customer needs to hold not only the tokens but also some Ether to pay for the gas. Gas-less or meta-transactions provide a way for users to be able to make such transactions without paying for the gas. The idea is to have the customer provide a “signed transaction” that can be transmitted off-chain to the seller who can then store it digitally and execute (cash out) on-chain at will. Read a fascinating insight into this concept here: A Long Way To Go: On Gasless Tokens and ERC20-Permit

Transaction signing is central to understanding how gas less transactions work. Lets start with a primer on different signature mechanisms available in Ethereum and their limitations to understand how EIP-712 has become the trusted standard for gas less transactions.

Ethereum Signatures

Ethereum uses ECDSA Signatures which, like real world handwritten signatures, are used to ensure the integrity and to establish the ownership of digital artifacts. An Ethereum signature guarantees that:

  1. The entity holding the private key, and no one else, signed the data.
  2. The data were not modified after it was signed.

In Ethereum, there are two kinds of artifacts that can be signed:

  • A Message: For example, signing some text off-chain and sending the signature to an on-chain smart contract. This scenario will be discussed later in the context of EIP-712.
  • A Transaction: For example, a user transferring some Ether to an another account. Check the section on eth_sign for more on this.

So how does signing work? As presented in Mastering Ethereum, the message to be signed (m) is first reduced to 32-bytes by computing a Keccack256 hash of it. Then all we need is the private key of the account signing the message to return us the signature (Sig):

Sig = Fsig (Fkeccak256 (m), k)

where:
k = private key
m = message
Fkeccak256 = Keccak-256 hash function
Fsig = signing algorithm
Sig = resulting signature

The resulting signature (Sig) is made up of two integers {r,s} which are the standard components of any ECDSA signature. If the message (m) changes, then {r,s} change as well so every signature is unique to the message that was signed. There is also a value “v” (recovery identifier) that is used to as an aid to speed up the algorithm that recovers public key from a signature. For the purpose of our discussion, we simply need to understand that {v, r, s) together constitute the signature and serve as a guard against tampering of the message that was signed.

For more on message signing and signatures see:

But what about Fsig , the signing algorithm that actually signs the message? Now because self-custody wallets are a widely used tool for signing messages/transactions, lets look at the 3 signing algorithms supported by Metamask:

  1. eth_sign
  2. personal_sign
  3. signTypedData_v4

eth_sign

This is the original signing method method supported by Metamask and essentially lets you sign the hash of an arbitrary message or transaction. Now because the user only sees the hash and not the actual message/transaction, they might not really know what are they signing.

Here is the process flow:

message -> hash -> Metamask displays the hash -> user signs the hash

This quirk of eth_sign was being used to scam people before Metamask team decided to curb it by displaying a warning sign on the signing page and disabling eth_sign by default although it can still be enabled through a setting hidden deep within Metamask. I thought it will be fun to try creating a transaction from scratch to show how deeply ingrained is eth_sign to the Ethereum ecosystem and how dangerous it can be when users can’t see what they are actually signing.

For this we will use ethers.js in a html file to create, sign and transmit an Ethereum transaction to transfer 100 Wei to an EOA on the Sepolia test network.

<!--eth_sign.html-->
<html>
  <head>
  <!--Import Ethers.js library-->
  <script src="https://cdn.ethers.io/lib/ethers-5.2.umd.min.js"
        type="application/javascript"></script>
  </head>

  <body>
    <script type="text/javascript">

	// Connect to Metamask wallet on button click
	async function connectandsign() {

		// Connect to Metamask to use as Web3 provider
		const provider = new ethers.providers.Web3Provider(window.ethereum);

	  	// Prompt Metamask to show the login page
		let acct = await provider.send("eth_requestAccounts", []);

		// First account in the list is the currently logged in account
		const senderAcct = acct[0]; // Sender's address
		console.log("Currently selected Metamask account:", senderAcct);
		console.log("Account's current balance is: ", parseInt(await provider.getBalance(senderAcct)));

		// Construct the transaction
		const tx = {
			to: "0x611E72c39419168FfF07F068E76a077588225798", // Receiver's address
			value: 100, // Transfer 100 wei
			nonce: await provider.getTransactionCount(senderAcct, "latest"), // Current nonce
			gasPrice: ethers.utils.hexlify(parseInt(await provider.getGasPrice())), // Current gas price
			gasLimit: ethers.utils.hexlify(22000), // Set gas limit
			chainId: 11155111, // Sepolia testnet's chainid
		}
		
		// RLP encode the transaction
		const rawTx = ethers.utils.serializeTransaction(tx);
		console.log("rlp encoded txn: ", rawTx);

		// Ethers.js converts all eth_sign calls to personal_sign if the wallet is Metamask
		// We need this to make ethers.js believe we are not using Metamask
		provider.provider.isMetaMask = false;

		// Hash the rlp encoded transaction
		const hashedTx = ethers.utils.keccak256(rawTx);

		// Ask Metamask to sign our hashed transaction using the eth_sign method
		const sig = await provider.send("eth_sign", [senderAcct, hashedTx]);
		console.log("Signed message from metamask: ", sig);

	    	// Sign our transaction
	    	// Supplying signature to serializeTransaction() populates (v,r,s) components in the transaction
		const signedTx = ethers.utils.serializeTransaction(tx, sig);
		console.log("Signed transaction:\n", ethers.utils.parseTransaction(signedTx));
	    
		// Send out the transaction	
		await provider.sendTransaction(signedTx);
        }
    </script>

    <!--Call connectandsign() on button click-->
    <button onclick="connectandsign()">Connect</button>
  </body>
</html>

For eth_sign to work, we will have to enable it in Metamask under “Advanced” settings:

Metamask does not like when locally saved webpages try to send it requests so we will use a simple Python webserver to serve it over localhost. Make sure you have Python3 installed and then run below command from the same path where you saved eth_sign.html:

python3 -m http.server 8000

You can now access your HTML page on port 8000 via localhost:

http://localhost:8000/eth_sign.html

Use the Connect button to open Metamask:

Hit F12 on Chrome browser to see the developer’s console and then click the “Sign” button on Metamask. You will be displayed a warning message and asked to confirm that you really wish to sign the message:

The transaction was successful and can be viewed at: https://sepolia.etherscan.io/tx/0x83d1ea473e1f00efd9eda1388074e3b7bf3833c3b315421806269bc7adaf0c91

From developer’s console you can get the message hash and the signature and use ecrecover() to verify the signature using the below contract:

// SPDX-License-Identifier: MIT

pragma solidity 0.8.21;

contract VerifySignature
{
    // Returns signer's address 
    function recoverSignerFromSignature(bytes memory signature, bytes32 message) pure external returns (address, uint8, bytes32, bytes32) {
        
        // Extract {r,s,v} components from the signature
        bytes32 r; 
        bytes32 s; 
        uint8 v;
        assembly
        {
            r := mload(add(signature, 32)) //skip 32 bytes and load the next 32 bytes
            s := mload(add(signature, 64)) //skip (32 + 32) bytes and load the next 32 bytes
            v := byte(0, mload(add(signature, 96))) // final byte
        }

        // Call ecrecover() to get the signer's address
        address signer = ecrecover(message, v, r, s);

        return (signer, v, r, s);
    }
}

As you see, by using eth_sign a user can unknowingly sign a transaction that drains all ETH from their account. To make signing safer, a successor to eth_sign was proposed by the name of personal_sign. Personal_sign is similar to eth_sign with the exception that the user would first see the message/transaction and then sign the hash. This would give them a chance to understand the contents of the transaction they are signing.

personal_sign

Personal_sign is the successor to eth_sign and differs from eth_sign in the following two ways:

  1. The actual message/transaction is displayed to the user on Metamask before signing which lets the user see its content beforehand.
  2. Personal_sign prepends the message with “\x19Ethereum Signed Message:\n<message length>” before it is hashed and signed. This is done to avoid a replay of transaction on other Ethereum compatible blockchains.

Here is the process flow:

message -> Metamask displays the message -> Metamask prepends “\x19Ethereum Signed Message:\n<message length>” -> hash -> user signs the hash

Lets walk through an example where we sign a message using personal_sign and then send the signature over to a “VerifySignature” contract. The contract will then calculate the address of the sender from {v, r, s} components via ecrecover().

Create a personal_sign.html file and save it in a new folder:

<!--personal_sign.html-->
<html>
  <head>
  <!--Import Ethers.js library-->
  <script src="https://cdn.ethers.io/lib/ethers-5.2.umd.min.js"
        type="application/javascript"></script>
  </head>

  <body>
    <script type="text/javascript">

        // Connect to Metamask wallet on button click
        async function connectandsign() {

          // Connect to Metamask to use as Web3 provider
          const provider = new ethers.providers.Web3Provider(window.ethereum);

          // Prompt Metamask to show the login page
          let acct = await provider.send("eth_requestAccounts", []);

          // First account in the list is the currently logged in account
          const signedAcct = acct[0];
          console.log(signedAcct);

          // This is the message we are signing
          const message = "Our test message";

          // Ask Metamask to sign our message using the personal_sign method
          const sig = await provider.send("personal_sign", [signedAcct, message]);
          console.log(sig);
        }
    </script>

    <!--Call connectandsign() on button click-->
    <button onclick="connectandsign()">Connect</button>
  </body>
</html>

Here, we are importing Ethers.js library and using it to have Metamask sign “Our test message” using the personal_sign method.

Fire up a local web server:

python3 -m http.server 8000

You can now access personal_sign.html on port 8000 via localhost:

http://localhost:8000/test.html

Use the Connect button to open Metamask:

Metamask pops up displaying the actual message you are about to sign:

Hit Sign and then F12 to open the Developer’s console to see the signed message:

Now suppose we have a webapp that lets the user create a signature in this manner and then sends it to a contract to process. One of the first actions this contract will have to take is to figure out who created the signature. Lets write such a Solidity contract that uses ecrecover() to calculate the sender from the signature and the message supplied as arguments. Pay attention to how this contract takes into consideration that Metamask will prepend “\x19Ethereum Signed Message:\n16” to the message before hashing and signing it. Open Remix and create a new contract:

// SPDX-License-Identifier: MIT

pragma solidity 0.8.21;

contract VerifySignature
{
    // Returns signer's address 
    function recoverSignerFromSignature(bytes memory signature, string memory message) pure external returns (address) {
        
        // Extract {r,s,v} components from the signature
        bytes32 r; 
        bytes32 s; 
        uint8 v;
        assembly
        {
            r := mload(add(signature, 32)) //skip 32 bytes and load the next 32 bytes
            s := mload(add(signature, 64)) //skip (32 + 32) bytes and load the next 32 bytes
            v := byte(0, mload(add(signature, 96)))
        }

        // ecrecover() accepts bytes not string
        // bytes16 because the signed message "Our test message" is 16 bytes long
        bytes16 stringToBytes16 = bytes16(abi.encodePacked(message));

        // Metamask prepends "\x19Ethereum Signed Message:" to all messages before hashing as a security measure
        // \n16 represents the length of the message "Our test message" that was signed
        bytes32 msgHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n16", stringToBytes16));

        // Call ecrecover() to get the signer's address
        address signer = ecrecover(msgHash, v, r, s);

        return signer;
    }
}

Call recoverSignerFromSignature() with the signature and message as arguments to see the sender:

ecrecover() was able to correctly figure out the signer’s address from the sig and msg we provided.

signTypedData_v4

So far we have we have tried to made signing safer by enabling user to see what is in the message/transaction that they are signing. signTypedData_v4 takes this a step further by allowing typed data to be signed instead of just plain text.

signTypedData_v4 mandates that the data to be signed has two components:

  1. Domain: Defines which smart contract is the message intended for.
  2. Message: The typed message to be signed.

The domain narrows down which smart contract can use the signature. Remember, we are trying to avoid the misuse (replay) of a signed message. The domain can have the following 5 fields however you do not necessarily need to define all of these:

  • Name: The name of the smart contract that the signed message is for.
  • Version: The version of the smart contract this message is for. This ensures a signature for an older version can’t be replayed across different versions of a smart contract.
  • ChaniId: The chain id of the network to ensure the signature can’t be replayed across chains.
  • VerifyingContract: The address of the smart contract this message is for.
  • Salt: A 32-byte value to be used as a last resort disambiguating measure.
domain: {
     		name: "EIP712Verifier",
     		version: "1",
     		chainId: 11155111,
     		verifyingContract: "0xDA0bab807633f07f013f94DD0E6A4F96F8742B53",
     		salt: "0xf504df746ac264333478b3234796623dbb22c2d3dba2a0aaad283aaaabbd3288"
}

Since EIP712 works with typed messages, we need to define two data types as well, one each for domain and message. For example, the type corresponding to our domain is presented below. The EIP712Domain is a required keyword and is reserved to define a domain type.

EIP712Domain: [
		{name: "name", type: "string"},
		{name: "version", type: "string"},
		{name: "chainId", type: "uint256"},
		{name: "verifyingContract", type: "address"},
		{name: "salt", type: "bytes32"}
]

The message on the other hand, is a structure that can contain multiple typed fields of your choice. For instance, a message authorizing transfer of tokens to an address could be made of: {amount, to}

message: {
    		amount: 100,
	    	to: "0x611E72c39419168FfF07F068E76a077588225798"
}

Corresponding message type definition:

MessageType: [
		{name: "amount", type: "uint256"},
		{name: "to", type: "address"}
]

Lets write a HTML front end to build an EIP712 compliant message and then sign it using a signTypedData_v4 call to Metamask. We will then pass the signature to the EIP712Verifier contract that will verify the address using the ecrecover() method. The account returned by ecrecover() must be same as the account used in Metamask to sign the message to ensure the signature is genuine.

The front end “SignTypedData.html”:

<!--SignTypedData.html-->
<html>
  <head>
  <!--Import Ethers.js library-->  
  <script src="https://cdn.ethers.io/lib/ethers-5.2.umd.min.js"
        type="application/javascript"></script>
  </head>

  <body>
    <script type="text/javascript">

	// Connect to Metamask wallet on button click
	async function connectandsign() {

		// Build the data to sign
		const data = JSON.stringify({
			types: {
				EIP712Domain: [
					{name: "name", type: "string"},
					{name: "version", type: "string"},
					{name: "chainId", type: "uint256"},
					{name: "verifyingContract", type: "address"},
					{name: "salt", type: "bytes32"}
				],
				MessageType: [
					{name: "amount", type: "uint256"},
					{name: "to", type: "address"}
				]
			},
			primaryType: "MessageType",
			domain: {
				name: "EIP712Verifier", // Name of contract that verifies our signature
				version: "1", // Version number of EIP712Verifier contract
				chainId: 11155111, // Sepolia testnet's chainid
				verifyingContract: "0xDA0bab807633f07f013f94DD0E6A4F96F8742B53", // Address of the EIP712Verifier contract
				salt: "0xf504df746ac264333478b3234796623dbb22c2d3dba2a0aaad283aaaabbd3288" // A 32-byte salt as a last resort disambiguating value
			},
			message: {
    				amount: 100,
	    			to: "0x611E72c39419168FfF07F068E76a077588225798"
	    		}			
		});	
	
		// Display the message we are signing
		console.log(data);
		
		// Connect to Metamask to use as Web3 provider
		const provider = new ethers.providers.Web3Provider(window.ethereum);

	  	// Prompt Metamask to show the login page
		let acct = await provider.send("eth_requestAccounts", []);

		// First account in the list is the currently logged in account
		const senderAcct = acct[0]; // Sender's address
		
		console.log("Signing account: ", senderAcct);
		
		// Sign the message
		const sig = await window.ethereum.request({method: "eth_signTypedData_v4", params: [senderAcct, data]});
		
		// Display the signature
		console.log("Signature: ", sig);
        }
    </script>

    <!--Call connectandsign() on button click-->
    <button onclick="connectandsign()">Connect</button>
  </body>
</html>

You will need to have Metamask installed to run this html. Once you hit the Connect button, Metamask will display a prompt with the contents of the message you are signing. After you hit “Sign”, you should press F12 to display the signature on the developer’s console.

Now lets see the EIP712Verifier contract that will verify the signature we have generated. The contract works by retracing the steps taken by Metamask before signing the Json it was supplied by our html frontend. It first computes a hash of the domain and then the message and then combines them both to create a digest. This digest is what was signed by Metamask. EIP712Verifier contract supplies this digest and {v, r, s} components to ecrecover() to compute the address that signed the message (digest). If the address returned by ecrecover() is same as the one used by Metamask, we can be sure that the message has been signed by the correct party and has not been tampered with.

// SPDX-License-Identifier: MIT

pragma solidity 0.8.24;

contract EIP712Verifier
{
    function recoverSignerFromEIP712Signature(bytes memory signature) view external returns (address, uint8, bytes32, bytes32) {

        // Prepare a hash of the domain information
        bytes32 domainHash = keccak256(abi.encode(
                                                keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)"),
                                                keccak256(bytes("EIP712Verifier")),
                                                keccak256(bytes("1")),
                                                11155111,
                                                address(this),
                                                bytes32(0xf504df746ac264333478b3234796623dbb22c2d3dba2a0aaad283aaaabbd3288)
                                                ));

        // Prepare a hash of the message
        bytes32 messageHash = keccak256(abi.encode(
                                                keccak256("MessageType(uint256 amount,address to)"),
                                                100,
                                                address(0x611E72c39419168FfF07F068E76a077588225798)
                                                ));
    
        // Prepare the final message that was signed by Metamask
        bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainHash, messageHash));
    
        // Extract {r,s,v} components from the signature
        bytes32 r; 
        bytes32 s; 
        uint8 v;
        assembly
        {
            r := mload(add(signature, 32))
            s := mload(add(signature, 64))
            v := byte(0, mload(add(signature, 96)))
        }
        
        address signer = ecrecover(digest, v, r, s);

        return (signer, v, r, s);    
    }
}

Compile this contract using Remix and then supply the signature to recoverSignerFromEIP712Signature():

We see the account returned by ecrecover() matches with the one which we used in Metamask so we can safely conclude the EIP712 signature was verified successfully.

Using EIP-712 – The easy way

One can go about implementing EIP-712 on their own however an easier and likely more secure way would be to use the contracts provided by OpenZeppelin for this purpose. For example the ERC20Permit.sol contract provides permit() that allows owner of an ERC20 to call _approve() via a gas-less EIP712 transaction. Note how ERC20Permit inherits from EIP712 that has the functionality to build the Domain hash while ERC20Permit.permit() builds the structHash. Using the OpenZeppelin’s implementation also helps you avoid security pitfalls like Signature Malleability.

Leave a Comment