Photo by Aakash Dhage on Unsplash
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:
Define data schema: as simple as declaring the Solidity struct your users will sign.
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)
User Comes to our Decentralized App and Connects wallet
Create a new message and Signs a message
A Signature is generated which contains the V,R,S (explained in detail below)
Relays the Transaction to our Backend
The Backend receives the request it will initialize a new Wallet with the admin private-key(This could be your wallet)
The Backend will send the data received from the User to the Smart Contract by using the Admin Wallet as Sender
The Smart Contract receives the Message and constructs the message hash on it’s end and creates a new digest
Finally using Message and V,R,S values the Public address of user is generated
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:
domain type hash (
DOMAIN_TYPEHASH
)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
or0x01
).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:
- Recovery process
- The
v
value is adjusted to ensure it's either27
or28
.
27: For uncompressed public keys
28: For compressed public keys
The adjusted
v
value is combined withr
ands
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