EIP-712 Typed structured data hashing and signing
EIP-712 Explained 📝
EIP 712 allows structured message signing. In Ethereum, signatures are a hash. These signatures authorize actions. e.g. When you want to send USDC from your account A to another account B, you actually call the transfer function on the USDC contract, supplying the required params i.e. amount and address to send to, then you sign this function call. You then relay this message to the network which verifies the signature and makes the function call, transferring the amount.
When you sign the message, you get a pop up on your wallet asking you to sign a message. This signature is usually unstructured and just a wall of hashes. People rely on trust i.e. A transfer button on a dex with a currency and amount selector. But the hash you’re usually presented with looks like a hash … basically unreadable to humans. But what if we could read what we were signing, what if we could see what actions we were doing, presented in a digestible way.
EIP712 presents a standard for structured message signing. Messages that we sign(which we will look at shortly) will have a structure and can be presented to the user providing more visible signatures. The standard also benefits smart contracts through composition and secure delegation. Why would we need this? Imagine a scenario where we have a contract that belongs to our DEX, say uniswap. We interact with another app. Let’s assume this app is an ai trading bot. The AI trading bot sits and monitors geopolitical news, macro-economics etc and decides how to trade our USDC. Either sell or buy. For this to happen, we would normally need to give it access to our funds, usually through approvals. The app can then call the necessary functions in the USDC contract. This is okay but you can imagine if we have many bots or are interacting with many DEXes we need to manage these approvals like you would want with a regular bank card for example. It becomes a pain.
However, let’s assume our DEX of choice is Uniswap. We know it and trust it and we already have uniswap approved for our funds. Uniswap implements eip712 signatures on a method called Permit2. Instead of managing multiple approvals, we could sign a structured Uniswap Permit2 message. This message won’t just be a random hash, we could clearly see the structure of the message we are signing on the wallet UI. And Uniswap could verify the signer each time a message is submitted.
So it would go like this. Trading bot presents us with a Uniswap structured message asking to have access to our funds. The message will show to the user wallet as “Trading bot is asking to spend up to 10,000 USDC from Uniswap”. We can then sign the message if this is the intent. It submits the message to the Uniswap contract. Uniswap can read the message, because it is structured and not a random hash, and see we are granting approval to a certain address(our bot) for a certain amount. It can then execute the swap instantly(signatureAllowance) or store approvals to be used later. The trading bot can go about its way monitoring real world events, and eventually when it sees a signal, it then submits a message to Uniswap saying “Swap 1,000 USDC to SOL from User A”. Typed messages allow users to see what they are signing and contracts to verify signer and intent and we will see how now.
The Standard 📐
EIP 712 proposes the following structure. We will look at the main implementation details and then how they all tie together shortly.
- Domain Separator
- Type definition
- Message Data
- HashStruct
🔷 Domain Separator
Think of this as the manifest.
struct EIP712Domain {
string name; // eg Uniswap
string version; // 1
uint256 chainId; // 1 for mainnet
address verifyingContract; // e.g address(this)
}
🔷 Type Definition
This defines what types exist in the message. This is the structure the messages users end up signing. And this is used to format the message before it is displayed to the user. Wallets read this. You can have more than one type in a contract for different actions
struct Transfer {
address from;
address to;
uint256 amount;
}
🔷 Message Data
This is the actual message being signed. Might look like this
const message = { from: "0xAlice", to: "0xBob", amount: 100n };
🔷 HashStruct
This is defined as (Typehash + data). Typehash defines what struct we are hashing. A contract can have many structs. So when encoding messages and calling functions we need to pass this information along. For the Transfer struct defined above, the typehash is
keccak256(Transfer(address from,address to,uint256 amount))
The hashstruct is defined as 👆️ + the data we want to send 👇️
bytes32 hashStruct = keccak256(
abi.encode(
TRANSFER_TYPEHASH,
transfer.from,
transfer.to,
transfer.amount
)
);
Having a hashstruct means we have intent encoded into the signing. If we have another struct for example approval that also has the same structure from,to,amount without signing the typehash intent could be lost i.e. we could submit an approval for a transfer or vice versa.
The Code 💻
A contract that follows EIP712 standard looks like this.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SimpleEIP712 {
bytes32 public immutable DOMAIN_SEPARATOR;
string public constant NAME = "SimpleEIP712";
string public constant VERSION = "1";
bytes32 private constant DOMAIN_TYPEHASH =
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
); // encode the type
// =============================================================
// STRUCTS
// =============================================================
struct Transfer { // transfer intent struct
address from;
address to;
uint256 amount;
}
struct Approval { // approval intent struct
address from;
address to;
uint256 amount;
}
// =============================================================
// TYPEHASHES
// =============================================================
bytes32 private constant TRANSFER_TYPEHASH =
keccak256(
"Transfer(address from,address to,uint256 amount)"
); // transfer intent hash
bytes32 private constant APPROVAL_TYPEHASH =
keccak256(
"Approval(address from,address to,uint256 amount)"
); // approval intent hash
// =============================================================
// CONSTRUCTOR
// =============================================================
constructor() {
DOMAIN_SEPARATOR = keccak256(
abi.encode(
DOMAIN_TYPEHASH, //fixed
keccak256(bytes(NAME)),
keccak256(bytes(VERSION)),
block.chainid, // this chain eg eth basechain
address(this)
)
);// makes sure this is only used on this contract, version, on this chain and the verifier can only be this contract
}
// =============================================================
// ACTIONS
// =============================================================
// user calls this method to transfer
function transfer(
Transfer calldata transferData,
bytes calldata signature // users will submit signatures for verification against params
) external {
bytes32 digest = _hashTransfer(
transferData
); // hash the user calldata
address signer =
_recoverSigner(
digest,
signature
); // attempt to get signature out
require(
signer ==
transferData.from,
"Invalid signer"
); // check for correct signer. ie prove user signed the message
// we can now transfer user funds 🎊
// Transfer logic here
}
function approve(
Approval calldata approvalData,
bytes calldata signature
) external {
bytes32 digest = _hashApproval(
approvalData
);
address signer =
_recoverSigner(
digest,
signature
);
require(
signer ==
approvalData.from,
"Invalid signer"
);
// we can now set approvals 🎊
// Approval logic here
}
// =============================================================
// HASHING
// =============================================================
// attempts to hash against user provided data
// the goal is to reproduce the same signature in order to verify the signer.
function _hashTransfer(
Transfer calldata transferData
)
internal
view
returns (bytes32)
{
// hashStruct(message)
bytes32 structHash =
keccak256(
abi.encode(
TRANSFER_TYPEHASH,
transferData.from,
transferData.to,
transferData.amount
)
);
// returns what the data would look like encoded for signature recovery
return keccak256(
abi.encodePacked(
"\x19\x01", // from eip 191
DOMAIN_SEPARATOR,
structHash
)
);
}
function _hashApproval(
Approval calldata approvalData
)
internal
view
returns (bytes32)
{
bytes32 structHash =
keccak256(
abi.encode(
APPROVAL_TYPEHASH,
approvalData.from,
approvalData.to,
approvalData.amount
)
);
// returns what the data would look like encoded for signature recovery
return keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
structHash
)
);
}
// =============================================================
// SIGNATURE RECOVERY
// =============================================================
function _recoverSigner(
bytes32 digest,
bytes memory signature
)
internal
pure
returns (address)
{
require(
signature.length == 65,
"Bad 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)
)
)
}
return ecrecover(
digest,
v,
r,
s
);
}
}
Once we have the contract defined, users can call it as follows
import { walletClient, publicClient } from "./wallet";
import { parseAbi, encodeFunctionData } from "viem";
const contractAddress = "0x1234567890123456789012345678901234567890"; // address of the SimpleEIP712 contract
// ======================================================
// STEP 1 — Define domain & types (must match contract)
// ======================================================
const domain = {
name: "SimpleEIP712", // matches NAME constant in contract
version: "1", // matches VERSION constant
chainId: 1n,
verifyingContract: contractAddress,
};
const types = {
Transfer: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
};
// ======================================================
// STEP 2 — Build the message
// ======================================================
const transferData = {
from: "0xAliceAddress",
to: "0xBobAddress",
amount: 100n,
};
// ======================================================
// STEP 3 — Sign the typed data (Alice signs off-chain)
// ======================================================
const signature = await walletClient.signTypedData({
account: "0xAliceAddress",
domain,
types,
primaryType: "Transfer",
message: transferData,
}); // user can see they are signing a message of type Transfer with the from, to and amount fields displayed correctly. ✅✅
// ======================================================
// STEP 4 — Submit to the contract
// Anyone can relay this — Alice doesn't need to be msg.sender
// This 👆️ is what helps with gasless transfers
// You can sign a message and anyone or any contract can submit it for you // 🌟🌟🌟🌟🌟
// ======================================================
const abi = parseAbi([
"function transfer((address from, address to, uint256 amount) transferData, bytes signature) external",
]);
const hash = await walletClient.writeContract({
address: contractAddress,
abi,
functionName: "transfer",
args: [transferData, signature],
}); // calling the transfer contract function and submitting the transfer data and the signature.
console.log("tx submitted:", hash);
// Optionally wait for confirmation
const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log("confirmed in block:", receipt.blockNumber);
The flow works as follows:
- Signing — The wallet presents the user with a readable typed message (from, to, amount). The user signs it off-chain.
- Submission — Anyone (the user, a bot, a relayer) submits the
transferData+signatureto the contract. No ETH needed to send this message — gas can be paid by a third party. - Verification — The contract reconstructs the digest by hashing the user-provided data with the DOMAIN_SEPARATOR (ensuring the signature was meant for this contract, chain, and version). It then recovers the signer address using
ecrecover. - Execution — If the recovered signer matches
transferData.from, the action executes. If not, it reverts.
This means contracts can read structured intent from signatures and verify it — enabling gasless transfers, permit patterns, and composable trust.
Essentially what happens is - 1. The wallet is able to present to the user typed data that they can read understand and have confidence signing ✅ 2. Once intent is signed, anyone can submit it. A contract or the user themselves or someone willing to pay gas for the transaction. ✅ 3. The contract gets transferData and signature. Then does the following.
function transfer(
Transfer calldata transferData,
bytes calldata signature
) external {
⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️
// attempt signature recovery by signing the user provided params and the domain elements. This basically reconstructs the signature. The goal is to answer, given to,from,amount, what would a signature signed with this contract's manifest look like. So we resign everything, then check if the signature is valid. Hash transfer internals 👇️
// hashStruct(message)
// bytes32 structHash =
// keccak256(
// abi.encode(
// TRANSFER_TYPEHASH, // make sure we are only calling this
// transferData.from, // if any of these change, signature will be invalid
// transferData.to,
// transferData.amount // changing the amount will invalidate the signature
)
);
// returns what the data would look like encoded for signature recovery
//return keccak256(
// abi.encodePacked(
// "\x19\x01", // from eip 191
// DOMAIN_SEPARATOR, // make sure only signed for this chain, version, contract etc
// structHash
//)
//);
⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️
bytes32 digest = _hashTransfer(
transferData
); // hash the user calldata
// beyond scope of this video
// I will leave a link on ECDSA
address signer =
_recoverSigner(
digest,
signature
); // attempt to get signature out
require(
signer ==
transferData.from,
"Invalid signer"
); // check for correct signer. ie prove user signed the message
// we can now transfer user funds 🎊
// Transfer logic here
}
We now have a contract that allows us to sign certain actions and verifies them. We can interact with the SimpleEIP712 contract by signing a transfer. It defines what we should sign and verification steps. We sign all the details ie domain(controls what contract, name, version we are signing for), types(used to verify the function we call), data(what we intend to sign.). We get clear wallet descriptions of what we are signing and contracts can verify intended actions by being handed structured typed data.
Going back to the example we had earlier, our trading bot can sign exactly the functions and data it needs from Uniswap — e.g. Allow the bot to spend 1,000 USDC. We get clear wallet UI describing exactly what we want. The bot stores the signature, monitors real world events, and when it sees a signal, it calls Uniswap - passing the user data and the signature. Uniswap verifies the data by reconstructing the signature and validating it before performing the action — swap, transfer, or approval — enabling composability without approvals.
Summary ❓
I’ve skimmed over some topics that are beyond the scope of this article. Specifically how signature verification happens. This is a bit of an advanced topic that I don’t quite have mastered enough to go into the math. Read about signature verification to understand this topic better. But - the basic idea is that given a signature x and an original message p, we can reconstruct the address z that signed it.
signing: privateKey + message → signature (r, s, v)
recovery: signature + message → publicKey → address (public Key)
So if Brian signs a message and gives us both the signature and the original message, we can calculate the public key i.e. address that Brian used to sign the message and validate that it is indeed he who signed the message. Read more on Elliptic Curve Cryptography Here and Here
References
- https://eips.ethereum.org/EIPS/eip-712
- https://eips.ethereum.org/EIPS/eip-191
- https://medium.com/coinmonks/eip-712-example-d5877a1600bd
- https://mansoor-eth.hashnode.dev/mastering-eip-712-a-practical-implementation-guide
- https://www.cyfrin.io/blog/elliptic-curve-digital-signature-algorithm-and-signatures
- https://www.cyfrin.io/blog/how-to-implement-permit2
- https://developers.uniswap.org/docs/protocols/permit2/concepts/signature-transfer
- https://blog.cloudflare.com/a-relatively-easy-to-understand-primer-on-elliptic-curve-cryptography/