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
}
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.
Self-explanatory
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
.
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);
}