Skip to content

Instantly share code, notes, and snippets.

@markodayan
Last active June 21, 2024 09:33
Show Gist options
  • Save markodayan/e05f524b915f129c4f8500df816a369b to your computer and use it in GitHub Desktop.
Save markodayan/e05f524b915f129c4f8500df816a369b to your computer and use it in GitHub Desktop.
EIP-712 Signing

How to implement EIP712

1. Design your data structures

Figure out the JSON structure of your data you intend your users to sign. For example:

{
    amount: 100, 
    token: “0x….,
    id: 15,
    bidder: {
        userId: 323,
        wallet: “0x….
    }
}

We can derive 2 data structures from the snippet: Bid which includes the big amount and the auction id, as well as Identity which specifies a userId and wallet address.

Next you can represent Bid and Identity as structs you would employ in a Solidity contract (refer to the specification in the EIP repo for specifics on data types:

Bid: {
    amount: uint256,
    bidder: Identity,
}

Identity: {
    userId: uint256,
    wallet: address
}

2. Design your domain separator

This mandatory field helps to prevent a signature meant for one dApp from working in another (preventing signature collision). The domain separator requires thought and effort at the architectural and implementation level. Developers must decide which fields to include or exclude based on what makes sense for their use case:

  • name: the dApp or protocol name (e.g. "Uniswap")
  • version: version number of your dApp or platform
  • chainId: EIP-155 chain id
  • verifyingContract: The Ethereum address of the contract that will verify the signature (accessible via this)
  • salt: A unique 32-byte value hardcoded into both the contract and the dApp meant as a last-resort to distinguish the dApp from others

In practice, a domain separator which uses all the above fields could look like this:

{
    name: "My amazing dApp",
    version: "2",
    chainId: "1",
    verifyingContract: "0x1c56346cd2a2bf3202f771f50d3d14a367b48070",
    salt: "0x43efba6b4ccb1b6faa2625fe562bdd9a23260359"
}

Wallet providers should prevent signing if the chainId does not match the network it is currently connected to. It was also be ensured that developers hardcode the chain ID into their smart contracts.

2.2 Install MetaMask above 4.14.0

Self-explanatory

3. Write signing code for your dApp

Your JavaScript dApp needs to be able to ask MetaMask to sign your data. First define data types:

const domain = [
    { name: "name", type: "string" },
    { name: "version", type: "string" },
    { name: "chainId", type: "uint256" },
    { name: "verifyingContract", type: "address" },
    { name: "salt", type: "bytes32" },
];
const bid = [
    { name: "amount", type: "uint256" },
    { name: "bidder", type: "Identity" },
];
const identity = [
    { name: "userId", type: "uint256" },
    { name: "wallet", type: "address" },
];

Next define your domain separator and message data.

const domainData = {
    name: "My amazing dApp",
    version: "2",
    chainId: parseInt(web3.version.network, 10),
    verifyingContract: "0x1C56346CD2A2Bf3202F771f50d3D14a367B48070",
    salt: "0xf2d857f4a3edcb9b78b4d503bfe733db1e3f6cdc2b7971ee739626c97e86a558"
};
var message = {
    amount: 100,
    bidder: {
        userId: 323,
        wallet: "0x3333333333333333333333333333333333333333"
    }
};

Layout your variables as such:

const data = JSON.stringify({
    types: {
        EIP712Domain: domain,
        Bid: bid,
        Identity: identity,
    },
    domain: domainData,
    primaryType: "Bid",
    message: message
});

Next make the eth_signTypedData_v3 sigining call to web3:

web3.currentProvider.sendAsync(
{
    method: "eth_signTypedData_v3",
    params: [signer, data],
    from: signer
},
function(err, result) {
    if (err) {
        return console.error(err);
    }
    const signature = result.result.substring(2);
    const r = "0x" + signature.substring(0, 64);
    const s = "0x" + signature.substring(64, 128);
    const v = parseInt(signature.substring(128, 130), 16);
    // The signature is now comprised of r, s, and v.
    }
);

Future releases (I think this might already be the case?) of these wallets are likely to rename it to just eth_signTypedData.

4. Write authentication code for verifying contract

Before a wallet provider signs EIP-712 type data, it first formats and hashes it. Therefore your contract needs to be able to do the same in order to use ecrecover to determine which address signed it. First declare your data types in Solidity, which you should already have done above:

struct Identity {
    uint256 userId;
    address wallet;
}
struct Bid {
    uint256 amount;
    Identity bidder;
}

Next define the type hashes to fit your data structures.

string private constant IDENTITY_TYPE = "Identity(uint256 userId,address wallet)";
string private constant BID_TYPE = "Bid(uint256 amount,Identity bidder)Identity(uint256 userId,address wallet)";

Also define the domain separator type hash. The code below with a chainId of 1 is meant for a contract on mainnet with name string "My amazing dApp" which must be hashed as well.

uint256 constant chainId = 1;
address constant verifyingContract = 0x1C56346CD2A2Bf3202F771f50d3D14a367B48070;
bytes32 constant salt = 0xf2d857f4a3edcb9b78b4d503bfe733db1e3f6cdc2b7971ee739626c97e86a558;
string private constant EIP712_DOMAIN = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)";
bytes32 private constant DOMAIN_SEPARATOR = keccak256(abi.encode(
    EIP712_DOMAIN_TYPEHASH,
    keccak256("My amazing dApp"),
    keccak256("2"),
    chainId,
    verifyingContract,
    salt
));

Next, write a hash function for each data type:

function hashIdentity(Identity identity) private pure returns (bytes32) {
    return keccak256(abi.encode(
        IDENTITY_TYPEHASH,
        identity.userId,
        identity.wallet
    ));
}
function hashBid(Bid memory bid) private pure returns (bytes32){
    return keccak256(abi.encodePacked(
        "\\x19\\x01",
       DOMAIN_SEPARATOR,
       keccak256(abi.encode(
            BID_TYPEHASH,
            bid.amount,
            hashIdentity(bid.bidder)
        ))
    ));

Last but not least, write your signature verification function:

function verify(address signer, Bid memory bid, sigR, sigS, sigV) public pure returns (bool) {
    return signer == ecrecover(hashBid(bid), sigV, sigR, sigS);
}
const ethUtil = require('ethereumjs-util');
const abi = require('ethereumjs-abi');
const chai = require('chai');
const typedData = {
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
Person: [
{ name: 'name', type: 'string' },
{ name: 'wallet', type: 'address' }
],
Mail: [
{ name: 'from', type: 'Person' },
{ name: 'to', type: 'Person' },
{ name: 'contents', type: 'string' }
],
},
primaryType: 'Mail',
domain: {
name: 'Ether Mail',
version: '1',
chainId: 1,
verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
},
message: {
from: {
name: 'Cow',
wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826',
},
to: {
name: 'Bob',
wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB',
},
contents: 'Hello, Bob!',
},
};
const types = typedData.types;
// Recursively finds all the dependencies of a type
function dependencies(primaryType, found = []) {
if (found.includes(primaryType)) {
return found;
}
if (types[primaryType] === undefined) {
return found;
}
found.push(primaryType);
for (let field of types[primaryType]) {
for (let dep of dependencies(field.type, found)) {
if (!found.includes(dep)) {
found.push(dep);
}
}
}
return found;
}
function encodeType(primaryType) {
// Get dependencies primary first, then alphabetical
let deps = dependencies(primaryType);
deps = deps.filter(t => t != primaryType);
deps = [primaryType].concat(deps.sort());
// Format as a string with fields
let result = '';
for (let type of deps) {
result += `${type}(${types[type].map(({ name, type }) => `${type} ${name}`).join(',')})`;
}
return result;
}
function typeHash(primaryType) {
return ethUtil.keccak256(encodeType(primaryType));
}
function encodeData(primaryType, data) {
let encTypes = [];
let encValues = [];
// Add typehash
encTypes.push('bytes32');
encValues.push(typeHash(primaryType));
// Add field contents
for (let field of types[primaryType]) {
let value = data[field.name];
if (field.type == 'string' || field.type == 'bytes') {
encTypes.push('bytes32');
value = ethUtil.keccak256(value);
encValues.push(value);
} else if (types[field.type] !== undefined) {
encTypes.push('bytes32');
value = ethUtil.keccak256(encodeData(field.type, value));
encValues.push(value);
} else if (field.type.lastIndexOf(']') === field.type.length - 1) {
throw 'TODO: Arrays currently unimplemented in encodeData';
} else {
encTypes.push(field.type);
encValues.push(value);
}
}
return abi.rawEncode(encTypes, encValues);
}
function structHash(primaryType, data) {
return ethUtil.keccak256(encodeData(primaryType, data));
}
function signHash() {
return ethUtil.keccak256(
Buffer.concat([
Buffer.from('1901', 'hex'),
structHash('EIP712Domain', typedData.domain),
structHash(typedData.primaryType, typedData.message),
]),
);
}
const privateKey = ethUtil.keccak256('cow');
const address = ethUtil.privateToAddress(privateKey);
const sig = ethUtil.ecsign(signHash(), privateKey);
const expect = chai.expect;
expect(encodeType('Mail')).to.equal('Mail(Person from,Person to,string contents)Person(string name,address wallet)');
expect(ethUtil.bufferToHex(typeHash('Mail'))).to.equal(
'0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2',
);
expect(ethUtil.bufferToHex(encodeData(typedData.primaryType, typedData.message))).to.equal(
'0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2fc71e5fa27ff56c350aa531bc129ebdf613b772b6604664f5d8dbe21b85eb0c8cd54f074a4af31b4411ff6a60c9719dbd559c221c8ac3492d9d872b041d703d1b5aadf3154a261abdd9086fc627b61efca26ae5702701d05cd2305f7c52a2fc8',
);
expect(ethUtil.bufferToHex(structHash(typedData.primaryType, typedData.message))).to.equal(
'0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e',
);
expect(ethUtil.bufferToHex(structHash('EIP712Domain', typedData.domain))).to.equal(
'0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f',
);
expect(ethUtil.bufferToHex(signHash())).to.equal('0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2');
expect(ethUtil.bufferToHex(address)).to.equal('0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826');
expect(sig.v).to.equal(28);
expect(ethUtil.bufferToHex(sig.r)).to.equal('0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d');
expect(ethUtil.bufferToHex(sig.s)).to.equal('0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562');
// identify patterns in contract parsing of 712 standard
contract BlahBlah {
function executeSetIfSignatureMatch (
uint8 v,
bytes32 r,
bytes32 s,
address sender,
uint256 deadline,
uint x
) external {
require(block.timestamp < deadline, "Signed transaction expired");
uint chainId;
assembly {
chainId := chainId
}
bytes32 eip712DomainHash = keccak256(
abi.encode(
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
),
keccak256(bytes("SetTest")),
keccak256(bytes("1")),
chainId,
address(this)
)
);
bytes32 hashStruct = keccak256(
abi.encode(
keccak256("set(address sender,uint x,uint deadline"),
sender,
x,
deadline
)
);
// 1. hashing the data (above is part of this) and generating the hashes
bytes32 hash = keccak256(abi.encodePacked("\x19\x01", eip712DomainHash, hashStruct));
// 2. use the data hashes and the signature to generate the public key opf the signer using ercecover method
address signer = ecrecover(hash, v, r, s);
require(signer == sender, "MyFunction: invalid signature");
require(signer != address(0), "ECDSA: invalid signature");
set(x);
}
}
import React, { Component } from "react";
import SimpleStorageContract from "./contracts/SimpleStorage.json";
import getWeb3 from "./getWeb3";
import "./App.css";
var ethUtil = require('ethereumjs-util');
var sigUtil = require('eth-sig-util');
class App extends Component {
state = { storageValue: 0, web3: null, accounts: null, contract: null };
componentDidMount = async () => {
try {
// Get network provider and web3 instance.
const web3 = await getWeb3();
// Use web3 to get the user's accounts.
const accounts = await web3.eth.getAccounts();
// Get the contract instance.
const networkId = await web3.eth.net.getId();
const deployedNetwork = SimpleStorageContract.networks[networkId];
const instance = new web3.eth.Contract(
SimpleStorageContract.abi,
deployedNetwork && deployedNetwork.address,
);
// Set web3, accounts, and contract to the state, and then proceed with an
// example of interacting with the contract's methods.
this.setState({ web3, accounts, contract: instance }, this.runExample);
} catch (error) {
// Catch any errors for any of the above operations.
alert(
`Failed to load web3, accounts, or contract. Check console for details.`,
);
console.error(error);
}
};
runExample = async () => {
const { accounts, contract } = this.state;
// Get the value from the contract to prove it worked.
const response = await contract.methods.get().call();
// Update state with the result.
this.setState({ storageValue: response });
};
signData = async () => {
const { web3, accounts, contract } = this.state;
var signer = accounts[0];
var deadline = Date.now() + 100000;
console.log(deadline);
var x = 157;
web3.currentProvider.sendAsync({
method: 'net_version',
params: [],
jsonrpc: "2.0"
}, function (err, result) {
const netId = result.result;
console.log("netId", netId);
const msgParams = JSON.stringify({types:
{
EIP712Domain:[
{name:"name",type:"string"},
{name:"version",type:"string"},
{name:"chainId",type:"uint256"},
{name:"verifyingContract",type:"address"}
],
set:[
{name:"sender",type:"address"},
{name:"x",type:"uint"},
{name:"deadline", type:"uint"}
]
},
//make sure to replace verifyingContract with address of deployed contract
primaryType:"set",
domain:{name:"SetTest",version:"1",chainId:netId,verifyingContract:"0x803B558Fd23967F9d37BaFe2764329327f45e89E"},
message:{
sender: signer,
x: x,
deadline: deadline
}
})
var from = signer;
console.log('CLICKED, SENDING PERSONAL SIGN REQ', 'from', from, msgParams)
var params = [from, msgParams]
console.dir(params)
var method = 'eth_signTypedData_v3'
web3.currentProvider.sendAsync({
method,
params,
from,
}, async function (err, result) {
if (err) return console.dir(err)
if (result.error) {
alert(result.error.message)
}
if (result.error) return console.error('ERROR', result)
console.log('TYPED SIGNED:' + JSON.stringify(result.result))
const recovered = sigUtil.recoverTypedSignature({ data: JSON.parse(msgParams), sig: result.result })
if (ethUtil.toChecksumAddress(recovered) === ethUtil.toChecksumAddress(from)) {
alert('Successfully ecRecovered signer as ' + from)
} else {
alert('Failed to verify signer when comparing ' + result + ' to ' + from)
}
//getting r s v from a signature
const signature = result.result.substring(2);
const r = "0x" + signature.substring(0, 64);
const s = "0x" + signature.substring(64, 128);
const v = parseInt(signature.substring(128, 130), 16);
console.log("r:", r);
console.log("s:", s);
console.log("v:", v);
await contract.methods.executeSetIfSignatureMatch(v,r,s,signer, deadline, x).send({ from: accounts[0] });
})
})
}
render() {
if (!this.state.web3) {
return <div>Loading Web3, accounts, and contract...</div>;
}
return (
<div className="App">
<h2>EIP 712 Example</h2>
<p>
Try changing the value stored on <strong>line 51</strong> of App.js.
</p>
<div>The stored value is: {this.state.storageValue}</div>
<button onClick={() => this.signData()}> Press to sign </button>
</div>
);
}
}
export default App;
pragma solidity ^0.8.3;
contract UniswapExample {
bytes32 public DOMAIN_SEPARATOR;
string public constant name = "TestName";
constructor() public {
uint chainId;
assembly {
chainId := chainid
}
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
keccak256(bytes(name)),
keccak256(bytes('1')),
chainId,
address(this)
)
);
}
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
// 1. Hash the data and generate hashes
bytes32 digest = keccak256(
abi.encodePacked(
'\x19\x01',
DOMAIN_SEPARATOR,
keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
)
);
// 2. use the data hashes and the signature to generate the public key opf the signer using ercecover method
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress != address(0) && recoveredAddress == owner, 'TestContract: INVALID_SIGNATURE');
_approve(owner, spender, value);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment