Mastering EIP-712: A Practical Implementation Guide

Creating a full-stack example to implement EIP-712 by Relaying your Transactions

Message signing in Ethereum has a surprisingly complicated history.

Best practices and economic realities have evolved considerably over the years. In Ethereum's earliest days, you could get away with storing any and all activity onchain without breaking the bank. Years later, when demand for block space grew and even simple transactions priced many users out, app developers were faced with very different questions, e.g., "How can I maximize the user experience in as few transactions as possible?" As the cost of transactions grew, so too did the popularity of replacing them with signed messages, where possible.

For many use cases, submitting a transaction is overkill; an application just needs to know that an account owner wants to take some action. That action could be logging in to a service, posting on a social network, or anything else that doesn't necessarily need to update onchain state. In these cases, a signed message and some offchain processing gets the job done.

For activity that does need to make its way onchain, message signing can permit relayers to subsidize gas for users. This enables app developers to remove the biggest source of onboarding friction: wallet download, setup, and funding – while still providing users with a wallet they fully own. The rise of account abstraction tooling and "Layer 2" scaling solutions have made this economically reasonable for many use cases and app developers can factor the transaction fee subsidies into their cost of user acquisition.

TL;DR – signing and sending a message is a solved problem. Modern cryptography allows for an app developer to be certain that a message signer is in possession of a private key without revealing that private key. The issue is that the user experience is miserable – and even outright dangerous – without an agreed upon standard for what can be signed, how to display it, and how to parse it.

In order to be efficiently transmitted, the content a user signs needs to be hashed, but presenting a user with an obscure hash to sign is, at best, uninformative and, at worst, an opportunity for bad actors to drain your account.

Before EIP-712

Back in the day, It was difficult for users to verify the data they were asked to sign, which made it all too easy for them to place more trust than they should in dApps that use signed messages as the basis for consequential value transfers.

So, the Ethereum developer community came up with a way of knowing precisely what users are signing, without having to go through the trouble of reconstructing a cryptographic hash all by themselves.

EIP-712: Let’s sign data !

EIP-712 proposes a standardized way of structuring the data to be signed. Instead of hashing arbitrary data, the proposal defines a data schema in a human-readable format.

So now, instead of just signing a bytes string completely unreadable, you will be able to know exactly what are you signing in a dapp interaction through any wallet, for instance, Metamask.

Then, the undisputed benefits are:

  • Interoperability: Both dapps and services can now use just one and standardized message format, ensuring compatibility.

  • Improved security: Easier to understand the data being signed and reduces the risk of misinterpretation.

Signing breakdown

To implement EIP-712 a smart contract must follow the following steps:

  1. Define data schema: as simple as declaring the Solidity struct your users will sign.

  2. Design your DOMAIN_SEPARATOR: This is a mandatory field to avoid a signature collision.

For instance, it is far more than possible that two smart contracts in the chain set the same data schema:

struct A {
  uint256 balance;
  address user;
}

Then, by providing a unique domain separator there´s no problem with defining identical data schemes.

3. Develop sign function: As simple as encoding the data schema and the domain separator field. This step could also be done through web3 API by calling

ethers.signer.provider.send("eth_signTypedData_v4",[accounts[0],JSON.stringify(msg.params)])

4. Signature verification functions: Here you have to recover the signer address through the usage of cryptographic functions (for instance, ECDSA) and then check that the provided address matches the signer.

What we will Implement ( Summary)

  1. User Comes to our Decentralized App and Connects wallet

  2. Create a new message and Signs a message

  3. A Signature is generated which contains the V,R,S (explained in detail below)

  4. Relays the Transaction to our Backend

  5. The Backend receives the request it will initialize a new Wallet with the admin private-key(This could be your wallet)

  6. The Backend will send the data received from the User to the Smart Contract by using the Admin Wallet as Sender

  7. The Smart Contract receives the Message and constructs the message hash on it’s end and creates a new digest

  8. Finally using Message and V,R,S values the Public address of user is generated

  9. If the Generated addresses matches the User’s Address , the message is written On-chain by deducting Fee from Admin’s Wallet and User performs a Gas-less Transaction

Fully working example

We are gonna make a simple Smart Contract which takes a greeting message along with a deadline

// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.0;

contract Greeter {
    string public greetingText = "Hello World!";
    address public greetingSender;  

    struct EIP712Domain {
        string  name;
        string  version;
        uint256 chainId;
        address verifyingContract;
    }

    struct Greeting {
        string text;
        uint deadline;
    }

    bytes32 DOMAIN_SEPARATOR;

    constructor () {
        DOMAIN_SEPARATOR = domainHash(EIP712Domain({
            name: "Ether Mail",
            version: '1',
            chainId: block.chainid,
            verifyingContract: address(this)
        }));
    }

    function domainHash(EIP712Domain memory eip712Domain) internal pure returns (bytes32) {
        return keccak256(abi.encode(
            keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
            keccak256(bytes(eip712Domain.name)),
            keccak256(bytes(eip712Domain.version)),
            eip712Domain.chainId,
            eip712Domain.verifyingContract
        ));
    }

    function structHash(Greeting memory greeting) internal pure returns (bytes32) {
        return keccak256(abi.encode(
            keccak256("Greeting(string text,uint deadline)"),
            keccak256(bytes(greeting.text)),
            greeting.deadline
        ));
    }

    function verify(Greeting memory greeting, address sender, uint8 v, bytes32 r, bytes32 s) public view returns (bool) {
        bytes32 digest = keccak256(abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            structHash(greeting)
        ));
        return ecrecover(digest, v, r, s) == sender;
    }

    function greet(Greeting memory greeting, address sender, uint8 v, bytes32 r, bytes32 s) public {
        require(verify(greeting, sender, v, r, s), "Invalid signature");
        require(block.timestamp <= greeting.deadline, "Deadline reached");
        greetingText = greeting.text;
        greetingSender = sender;
    }
}

Domain Separator

The domain separator (DOMAIN_SEPARATOR) is a unique and contextually-based piece of data that serves a vital function by including domain-specific information to establish a foundational security layer. For example, the inclusion of chainId prevents a signed message from being executed on a duplicate contract that exists on a different chain. For our example, the domain separator will be the hash of the following encoded contents concatenated together:

  1. domain type hash (DOMAIN_TYPEHASH)

  2. domain details (i.e., hashed name, hashed version, non-hashed chain id, non-hashed verifying contract)

Struct Hash

In our case we have a basic Greet struct so our struct hash would be structHash(Greeting memory greeting) where we will hash the Basic Greeting structure followed by greet and deadline

Principle of the Validation

When the contract owner’s address is known, the public key is reversed via the signed message, and the public key is turned to an address. If the address matches the initiator’s address, the verification succeeds.

In the above example, we are making use of the recover built-in function provided by solidity for bytes32.

However, let´s deep dive into this function. The same could accomplished by using the following function:

    function verify(Greeting memory greeting, address sender, uint8 v, bytes32 r, bytes32 s) public view returns (bool) {
     bytes32 digest = keccak256(abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            structHash(greeting)
        ));
        return ecrecover(digest, v, r, s) == sender;
}

The ecrecover function is used to recover the Ethereum address associated with a given signature and message hash.

V, R, S params explained

  • v: The recovery ID, which is typically the last byte of the signature (0x00 or 0x01).

  • r: The first 32 bytes of the signature.

  • s: The second 32 bytes of the signature.

Validation process

The process of verifying the signature involves two sub-processes:

  1. Recovery process
  • The v value is adjusted to ensure it's either 27 or 28.

27: For uncompressed public keys

28: For compressed public keys

  • The adjusted v value is combined with r and s to recreate the full 65-byte signature.

  • Using the full signature and the provided messageHash, elliptic curve cryptography (ECDSA) algorithm is used to recover the public key.

  • From the recovered public key, the Ethereum address can be derived.

2. Address derivation

  • The Ethereum address is derived from the recovered public key using a hash function, usually Keccak-256.

The last 20 bytes of the hash result (160 bits) represent the Ethereum address.

Flow of the System

Flow of Smart Contract

On The Frontend Side

I will be attaching the Github repo for full reference , however a little snippet of how to Setup your structured data from the frontend side to send it to your Relayer (Backend Server)

const msgParams = {
      types: {
        EIP712Domain: [
          { name: "name", type: "string" },
          { name: "version", type: "string" },
          { name: "chainId", type: "uint256" },
          { name: "verifyingContract", type: "address" },
        ],
        Greeting: [
          { name: "text", type: "string" },
          { name: "deadline", type: "uint" },
        ],
      },
      primaryType: "Greeting",
      domain: {
        name: "Ether Mail",
        version: "1",
        chainId: chainId,
        verifyingContract: contractAddress,
      },
      message: {
        text: greeting,
        deadline: unixTimestamp,
      },
    };
    const signature = await signedDataSigner.provider.send(
      "eth_signTypedData_v4",
      [account.address, JSON.stringify(msgParams)]
    );
  };

Now Setting up the Relay-Service

Backend Relay Service

const express = require("express");
const { ethers, errors } = require("ethers");
const cors = require("cors");
require("dotenv").config();
const abi = require("../eip-712-frontend/src/app/contracts/abi.json");
const { Alchemy, Network, Wallet, Utils } = require("alchemy-sdk");
var corsOptions = {
  origin: "*",
  optionsSuccessStatus: 200, // some legacy browsers (IE11, various SmartTVs) choke on 204
};

const app = express();
app.use(cors(corsOptions));

const contractAddress = "0xf80d2D0D9CEEe7263923EC629C372FC14bcA0d89";
const PORT = 8080;
const { RPC_URL, PRIVATE_KEY, WALLET_ADDRESS, API_KEY } = process.env;
const settings = {
  apiKey: API_KEY,
  network: Network.LINEA_SEPOLIA, // Replace with your network.
};
const alchemy = new Alchemy(settings);

const provider = new ethers.providers.JsonRpcProvider(RPC_URL);

async function relayGreeting(
  greetingText,
  greetingDeadline,
  greetingSender,
  v,
  r,
  s
) {
  try {
    const signer = new ethers.Wallet(PRIVATE_KEY, provider);
    const contract = new ethers.Contract(contractAddress, abi, signer);

    const iface = contract.interface
        const calldata = iface.encodeFunctionData("greet",[[greetingText,greetingDeadline],greetingSender,
        v,
        r,
        s
        ])
        const nonce1 =await alchemy.core.getTransactionCount(WALLET_ADDRESS)

    const transaction = {
      from: WALLET_ADDRESS,
      to: contractAddress,
      value: ethers.utils.hexlify(0),
      gasPrice: ethers.utils.hexlify(300000000),
      gasLimit : ethers.utils.hexlify(1000000),
      nonce: nonce1,
      data: calldata,
      chainId:59141,
    };   
    console.log("tx data is " , transaction)
       const tx = await signer.signTransaction(transaction)
       const tx2 = await alchemy.transact.sendTransaction(tx);
       tx2.wait(3)
      console.log("Tx Hash is : ",tx2)
  } catch(error) {
    console.log("Failed to fetch data from the contract");
    console.error(error)
  }
}
app.get("/relay", (req, res) => {
  var greetingText = req.query["greetingText"];
  var greetingDeadline = req.query["greetingDeadline"];
  var greetingSender = req.query["greetingSender"];
  var v = req.query["v"];
  var r = req.query["r"];
  var s = req.query["s"];
  var message = greetingSender + " sent a greet: " + " " + greetingText;
  relayGreeting(greetingText, greetingDeadline, greetingSender, v, r, s);
  res.setHeader("Content-Type", "application/json");
  res.send({
    message: message,
  });
});
app.listen(PORT, () => {
  console.log(`Listening to port ${PORT}`);
});

Github Repo : https://github.com/MansoorButt/eip-712-fullstack-v1/tree/main