EIP-712, let's use it!
November 21, 2024
Background
This post is rewrited my post into English.
When I studided dApp dev using Blockchain, I faced some problems.
I made clone coding NFT open market, Opensea, and a DeFi Project, CDS, we planed ourselves. After both work, I found a point to imporve something in each project.
First, to be close to Opensea, I have to implement Atomic Transaction
, and that should be supported signed transactions.
Working on CDS project, there was a problem that client call twice to contract. One is for approve client's token asset to CDS contract by token contract, then other one is request to CDS contract function.
These problems have one common point. Alredy noticed, It is signed data. This post will handle how to use signed data.
EIP-712
Honestly, doing those projects, I could have chance for using signed data.
In CDS project, I use signed data to implement auth for User login by Metamask. But I used sign
function. But you must suffer from recognizing what data you sign by that function as above image. And It could be used to phishing because of this feature, so clients lose thrust about those site requiring signed data with this format. To solve this problem, EIP-712 is proposed.
EIP-712 specify data's types to be signed. You also should use other function, eth_signTypedData_v4
for signing. As different from sign
, this function show you the lists of datas you sign.
Example
[Function Permit in ERC20, UNISWAP]
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
bytes32 digest = keccak256(
abi.encodePacked(
'\x19\x01',
DOMAIN_SEPARATOR,
keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
)
);
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
_approve(owner, spender, value);
}
Above, you can see an example using EIP-712. Briefly, this function is for using burn the token from users who need to take assets deposited from pool.
Important code is following.
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
_approve(owner, spender, value);
With data sigined and value v, r, and s from the signature, you can get public address through recovering by ecrecover
function. If this address is same with the value of owner passed in permit function, the logic is consider the caller is validated. Then you can run _approve
.
To do same things, we should know how to prepare this things.
Typed Structured Data: S
The set of signable messages was transaction and 8 bytes bytestring. EIP-712 add Structured Data(S) to this set.
The format of S is following.
"\x19\x01" ‖ domainSeparator ‖ hashStruct(message)
The rules of S is following.
- Types have identifier like name and zero or more member variables.
- Member Types have Atomic Type, Dynamic Type, Reference Types.
- Atomic Types can be bytes1~bytes32, unit8~uint256, int8~int256, bool and address.
- Bytes and string is Dynamic Type. Type declaration is same as atomic type, but encoding format is different.
- Reference Types have array and structure. Arrays can have fixed or dynamic size. Structure reference other structure by name. Standard supports recursive types.
- S can include all of Struct types.
We can guess that it needs domainSeparator
and hashStruct(message)
for make Structured Data.
hashStruct Function
hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s)) where typeHash = keccak256(encodeType(typeOf(s)))
hashStruct
is a function to make given S fit into standard as we see above. It can be difficult to understand by just saying, but it is simple in this solidity example.
function hashStruct(Permit memory _permit) pure public returns(bytes32 hash){
return keccak256(abi.encode(
PERMIT_TYPEHASH,
_permit.from,
_permit.to,
_permit.amount
));
}
In this time, just ignore PERMIT_TYPEHASH
I will expalin next section. For implementing hashStruct, solidity supports abi.encode
so you just use it to make data encoded. keccak256
compress and hash your encoded data within 256 bits(32 bytes).
typeHash Definition
typeHash is literally the hashed value of types. We will represent example with Mail structure type. Mail is a structure having two addresses types, from and to, and one string type, contents. In this proposal, Mail structure should be formatted to be hashed.
Mail(address to,address from,string contents)
The point is keeping rules, white spaces and letter case. Specification is following
name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")"
domainSeparator Definition
domainSeparator is a structure type already decided. By domainSeparator, you can avoid several threat in using Signed Data Structure.
domainSeparator include
string name
: User readable domain name. Name of DApp or protocolstring version
: Domain's name.uint256 chainId
: ChainId intended to use.address verifyingContract
: To prevent phishing.bytes32 salt
: Temporary salt.
bytes32 private constant TYPE_HASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
constructor(string memory name, string memory version) {
_name = name.toShortStringWithFallback(_nameFallback);
_version = version.toShortStringWithFallback(_versionFallback);
_hashedName = keccak256(bytes(name));
_hashedVersion = keccak256(bytes(version));
_cachedChainId = block.chainid;
_cachedDomainSeparator = _buildDomainSeparator();
_cachedThis = address(this);
}
function _buildDomainSeparator() private view returns (bytes32) {
return keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(this)));
}
I take the example from EIP-712 Contract, openzeppelin. It is same almost like hashStruct where values become encoded and hashed. But save gas fee, the hashed value is just calculated once when deploying contracts and saved in contract's state variable.
Implementation
I will show you an example.
Referencing UniswapV2, I implement permit function on ERC-20 Contract. And Client can sign Type Structured Data. Contract verify the address taken from ecrecover with signed data, and finally approve appropriate amounts of token for target to use the assets free.
Sign by Clients
I made simple web application by react and Metamask.
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [amount, setAmount] = useState("");
const [signature, setSignature] = useState("");
const clickHandler = async ()=>{
const {ethereum} = window;
if (ethereum){
const account = await ethereum.request({method: 'eth_requestAccounts', params:[]});
setFrom(account[0]);
console.log(from, to, amount);
const typeData = {
types: {
EIP712Domain: [
{name:'name', type:'string'},
{name:'version', type:'string'},
{name:'chainId', type:'uint256'},
{name:'verifyingContract', type:'address'}
],
Permit: [
{name:'from', type:'address'},
{name:'to', type:'address'},
{name:'amount', type:'uint256'},
]
},
primaryType: "Permit",
domain: {
chainId: 1337,
name: "PermitToken",
version: "1.0",
verifyingContract: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"
},
message: {
from,
to,
amount
}
}
try {
const result = await ethereum.request({
method: "eth_signTypedData_v4",
params: [account[0], JSON.stringify(typeData)]
})
console.log(result);
setSignature(result);
} catch(err){
console.log(err);
}
}
}
You can see the typeData
.
To using eth_signTypedData_v4
, client should have a formatted data.
The data include
- types: Specify structure of data to sign.
- primaryType: Top level type of data to sign.
- domain: EIP712Domain. This should be same things in contract.
- message: Data to sign.
The eth_signTypedData_v4
returns 129 bytes encrypted data. It is called signature. And in EVM, it can be divided with r, s and v section.
r
is bytes from first to 64th in signature.
s
is bytes from 65th to 128th in signature.
v
is 129th bytes in signature.
Clients send this signature and origin data to server or contract. In my case, the target is contract.
Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
contract EIP712 is ERC20 {
bytes32 constant PERMIT_TYPEHASH = keccak256(
"Permit(address from,address to,uint256 amount)"
);
bytes32 public DOMAIN_SEPARATOR;
struct Permit {
address from;
address to;
uint256 amount;
}
constructor(string memory name_ , string memory symbol_)
ERC20(name_, symbol_){
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
keccak256(bytes("PermitToken")),
keccak256(bytes('1.0')),
1337,
address(this)
)
);
}
function permit(
address from,
address to,
uint256 amount,
uint8 v, bytes32 r, bytes32 s
) public{
bytes32 hashedPotato = hashStruct(Permit(from, to, amount));
bytes32 digest = keccak256(
abi.encodePacked(
'\x19\x01',
DOMAIN_SEPARATOR,
hashedPotato
)
);
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress == from, "ERC20: Not Owner");
_approve(from, to, amount);
}
function hashStruct(Permit memory _permit) pure public returns(bytes32 hash){
return keccak256(abi.encode(
PERMIT_TYPEHASH,
_permit.from,
_permit.to,
_permit.amount
));
}
}
By someone's need, a transction should be submitted by calling permit function in the contract we can see above with signature and data already made. Anyone having signature and data can call permit function. In this permit function, the logic just compare address from
and recovered address by ecrecover.