How To Sign EIP-712 Structured Data With MetaMask

MetaMask is one of the most used crypto wallets, but it offers much more than that. With its help, we have the possibility to digitally…

How To Sign EIP-712 Structured Data With MetaMask

MetaMask is one of the most used crypto wallets, but it offers much more than that. With its help, we have the possibility to digitally sign data structures, which can be utilized in many different ways. One option is to use MetaMask for authenticating our users. In this case, we identify our users with an Ethereum address instead of a password. The user proves ownership of the Ethereum address by digitally signing a data structure, and the signature is then validated on the server side. As MetaMask securely stores our private key and even offers the option to use hardware wallets, this type of authentication is much more secure than traditional password-based solutions.

There is also the option to sign certain API calls with MetaMask. For example, in the case of a financial service, in addition to MetaMask authentication, we can sign individual financial transactions separately, making them much more secure.

MetaMask signatures can be validated on the blockchain with the help of a smart contract. One area of use for this is metatransactions. With metatransactions, we can simplify the use of blockchain applications for users. In the case of metatransactions, the transaction fees are paid by a relay server instead of the user, so the user does not need to have cryptocurrency. The user simply puts together the transaction, signs it using MetaMask, and sends it to the relay server, which then forwards it to a smart contract. The smart contract validates the digital signature and executes the transaction.

After theory, let’s take a look at the practice.

The EIP-712 standard defines how to sign structured data packages in a standardized manner. MetaMask displays these structured data in a readable format for the user. An EIP-712 compliant structure, as shown on MetaMask (can be tested on this URL) looks like this:

The above transaction was generated using the following simple code:

    async function main() {
if (!window.ethereum || !window.ethereum.isMetaMask) {
console.log("Please install MetaMask")
return
}

const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
const chainId = await window.ethereum.request({ method: 'eth_chainId' });
const eip712domain_type_definition = {
"EIP712Domain": [
{
"name": "name",
"type": "string"
},
{
"name": "version",
"type": "string"
},
{
"name": "chainId",
"type": "uint256"
},
{
"name": "verifyingContract",
"type": "address"
}
]
}
const karma_request_domain = {
"name": "Karma Request",
"version": "1",
"chainId": chainId,
"verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
}

document.getElementById('transfer_request')?.addEventListener("click", async function () {
const transfer_request = {
"types": {
...eip712domain_type_definition,
"TransferRequest": [
{
"name": "to",
"type": "address"
},
{
"name": "amount",
"type": "uint256"
}
]
},
"primaryType": "TransferRequest",
"domain": karma_request_domain,
"message": {
"to": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC",
"amount": 1234
}
}
let signature = await window.ethereum.request({
"method": "eth_signTypedData_v4",
"params": [
accounts[0],
transfer_request
]
})
alert("Signature: " + signature)
})
}
main()

The eip712domain_type_definition is a description of a general structure, which contains the metadata. The name field is the name of the structure, the version field is the definition version of the structure, and the chainId and verifyingContract fields determine which contract the message is intended for. The executing contract verifies this metadata in order to ensure that the signed transaction is only executed on the target contract.

The karma_request_domain contains the specific value of the metadata defined by the EIP712Domain structure.

The actual structure that we send to MetaMask for signature is contained in the transfer_request variable. The types block contains the type definitions. Here, the first element is the mandatory EIP712Domain definition, which describes the metadata. This is followed by the actual structure definition, which in this case is the TransferRequest. This is the structure that will appear in MetaMask for the user. The domain block contains the specific value of the metadata, while the message contains the specific structure that we want to sign with the user.

The signature can be easily validated on the server side by using the eth-sig-util:

import { recoverTypedSignature } from '@metamask/eth-sig-util'

const address = recoverTypedSignature({
data: typedData,
signature: signature,
version: SignTypedDataVersion.V4
}))

The recoverTypedSignature function has three parameters. The first parameter is the structured data, the second is the signature, and the last is the signature version. The return value of the function is the recovered Ethereum address.

Now, let’s see how the signature can be validated on-chain by a smart contract. The code is from my karma money repo (you can read more about karma money here). The following TypeScript code sends a meta transaction to the karma money smart contract:

const types = {
"TransferRequest": [
{
"name": "from",
"type": "address"
},
{
"name": "to",
"type": "address"
},
{
"name": "amount",
"type": "uint256"
},
{
"name": "fee",
"type": "uint256"
},
{
"name": "nonce",
"type": "uint256"
}
]
}

let nonce = await contract.connect(MINER).getNonce(ALICE.address)
const message = {
"from": ALICE.address,
"to": JOHN.address,
"amount": 10,
"fee": 1,
"nonce": nonce
}

const signature = await ALICE.signTypedData(karma_request_domain,
types, message)
await contract.connect(MINER).metaTransfer(ALICE.address, JOHN.address,
10, 1, nonce, signature)
assert.equal(await contract.balanceOf(ALICE.address), ethers.toBigInt(11))

The types variable defines the structure of the transaction. The “from” is the address of the sender, while the “to” is the address of the recipient. The amount represents the quantity of tokens to be transferred. The fee is the “amount” of tokens that we offer to the relay node in exchange for executing our transaction and covering the cost in the native currency of the chain. The “nonce” serves as a counter to ensure the uniqueness of the transaction. Without this field, a transaction could be executed multiple times. However, thanks to the nonce, a signed transaction can only be executed once.

The signTypedData function provided by ethers.js makes it easy to sign EIP-712 structures. It does the same thing as the code presented earlier but with a simpler usage.

The metaTransfer is the method of the karma contract for executing meta-transaction.

Let’s see how it works:

    function metaTransfer(
address from,
address to,
uint256 amount,
uint256 fee,
uint256 nonce,
bytes calldata signature
) public virtual returns (bool) {
uint256 currentNonce = _useNonce(from, nonce);
(address recoveredAddress, ECDSA.RecoverError err) = ECDSA.tryRecover(
_hashTypedDataV4(
keccak256(
abi.encode(
TRANSFER_REQUEST_TYPEHASH,
from,
to,
amount,
fee,
currentNonce
)
)
),
signature
);

require(
err == ECDSA.RecoverError.NoError && recoveredAddress == from,
"Signature error"
);

_transfer(recoveredAddress, to, amount);
_transfer(recoveredAddress, msg.sender, fee);
return true;
}

In order to validate the signature, we must first generate the hash of the structure. The exact steps for doing this are described in detail in the EIP-712 standard, which includes a sample smart contract and a sample javascript code.

In summary, the essence is that we combine the TYPEHASH (which is the hash of the structure description) with the fields of the structure using abi.encode. Then produces a keccak256 hash. The hash is passed to the _hashTypedDataV4 method, inherited from the EIP712 OpenZeppelin contract in the Karma contract. This function adds metadata to our structure and generates the final hash, making structure validation very simple and transparent. The outermost function is ECDSA.tryRecover, which attempts to recover the signer’s address from the hash and signature. If it matches the address of the “from“ parameter, the signature is valid. At the end of the code, the actual transaction is executed, and the relay node performing the transaction receives the fee.

EIP-712 is a general standard for signing structures, making it just one of many uses for implementing meta-transactions. As the signature can be validated not only with smart contracts, it can also be very useful in non-blockchain applications. For example, it can be used for server-side authentication, where the user identifies themselves with their private key. Such a system can provide a high level of security typically associated with cryptocurrencies, allowing for the possibility of using a web application only with a hardware key. In addition, individual API calls can also be signed with the help of MetaMask.

I hope that this brief overview of the EIP-712 standard has been inspiring for many and that you will be able to utilize it in both blockchain-based and non-blockchain projects.