
{"id":214413,"date":"2026-08-17T16:43:18","date_gmt":"2026-08-17T16:43:18","guid":{"rendered":"https:\/\/mycryptomania.com\/?p=214413"},"modified":"2026-08-17T16:43:18","modified_gmt":"2026-08-17T16:43:18","slug":"eip-712-explained-sign-and-verify-typed-data-with-ethers-js-and-solidity","status":"publish","type":"post","link":"https:\/\/mycryptomania.com\/?p=214413","title":{"rendered":"EIP-712 Explained: Sign and Verify Typed Data with ethers.js and Solidity"},"content":{"rendered":"<p>Imagine your application wants a user to authorize an off-chain action:<\/p>\n<p>Transfer 100 USDC<br \/>to 0x&#8230;<br \/>nonce 42<br \/>deadline &#8230;<\/p>\n<p>You could serialize that data into a string and ask the wallet to sign it. But then subtle questions appear: Which serialization is canonical? Is 100 a string or an integer? Which contract is allowed to consume the signature? Can the same signature work on another\u00a0chain?<\/p>\n<p><strong>EIP-712<\/strong> solves the encoding side of this problem by defining a deterministic way to hash and sign typed structured data. Instead of signing arbitrary JSON text, the wallet signs a digest derived from explicit Solidity-like types, the message, and an application-specific domain.<\/p>\n<p>That makes EIP-712 particularly useful for permits, meta-transactions, order protocols, delegated actions, and other off-chain authorizations that are later verified on-chain.<\/p>\n<h3>Why signing plain strings is not\u00a0enough<\/h3>\n<p>Ethereum wallets can sign arbitrary messages using mechanisms such as personal_sign. This works well when the thing being signed really is a human-readable message.<\/p>\n<p>Structured application data is different.<\/p>\n<p>Suppose two applications serialize this object differently:<\/p>\n<p>{&#8220;to&#8221;:&#8221;0x&#8230;&#8221;,&#8221;amount&#8221;:&#8221;100&#8243;}<\/p>\n<p>and:<\/p>\n<p>{<br \/>  &#8220;amount&#8221;: &#8220;100&#8221;,<br \/>  &#8220;to&#8221;: &#8220;0x&#8230;&#8221;<br \/>}<\/p>\n<p>They may represent the same intent to a developer, but they are different byte\u00a0strings.<\/p>\n<p>Plain message signing also does not inherently describe Solidity types. A wallet sees bytes or text rather than an explicit structure such as address to, uint256 amount, and uint256\u00a0nonce.<\/p>\n<p>EIP-712 defines a typed encoding and hashing scheme instead. Wallets can use that structure to present meaningful fields to the user rather than an opaque serialized blob.<\/p>\n<h3>EIP-712 Explained: Domain, Types, and\u00a0Message<\/h3>\n<p>Consider a transfer authorization:<\/p>\n<p>const domain = {<br \/>  name: &#8220;ExampleApp&#8221;,<br \/>  version: &#8220;1&#8221;,<br \/>  chainId: 1,<br \/>  verifyingContract: &#8220;0x1234567890123456789012345678901234567890&#8221;,<br \/>};<br \/>const types = {<br \/>  Transfer: [<br \/>    { name: &#8220;to&#8221;, type: &#8220;address&#8221; },<br \/>    { name: &#8220;amount&#8221;, type: &#8220;uint256&#8221; },<br \/>    { name: &#8220;nonce&#8221;, type: &#8220;uint256&#8221; },<br \/>    { name: &#8220;deadline&#8221;, type: &#8220;uint256&#8221; },<br \/>  ],<br \/>};<br \/>const value = {<br \/>  to,<br \/>  amount,<br \/>  nonce,<br \/>  deadline,<br \/>};<\/p>\n<p>There are four important pieces.<\/p>\n<p><strong>Domain<\/strong> identifies the application context in which the signature is\u00a0valid.<\/p>\n<p><strong>Types<\/strong> define the exact structure and Solidity-compatible types being\u00a0signed.<\/p>\n<p><strong>Primary type<\/strong> is the root structure\u200a\u2014\u200aTransfer in this\u00a0example.<\/p>\n<p><strong>Message<\/strong> is the actual set of\u00a0values.<\/p>\n<p>The domain is what gives EIP-712 its <strong>domain separation<\/strong>. Two applications can sign structurally identical Transfer messages without necessarily producing interchangeable signatures.<\/p>\n<p>For production authorizations, two domain fields are especially important:<\/p>\n<p>chainId<br \/>verifyingContract<\/p>\n<p>chainId binds the signature to a network, while verifyingContract binds it to a particular contract address. Both become part of the EIP-712 domain separator and therefore affect the final\u00a0digest.<\/p>\n<p>Without appropriate domain separation, a signature intended for one context may be meaningful in\u00a0another.<\/p>\n<h3>How the EIP-712 Hash Is\u00a0Built<\/h3>\n<p>EIP-712 does not sign your JavaScript object or its JSON serialization.<\/p>\n<p>Conceptually, the final digest\u00a0is:<\/p>\n<p>keccak256(<br \/>  0x1901 ||<br \/>  domainSeparator ||<br \/>  hashStruct(message)<br \/>)<\/p>\n<p>The 0x1901 prefix comes from the signed-data encoding scheme used by\u00a0EIP-712.<\/p>\n<p>For our Transfer structure, its encoded type is effectively:<\/p>\n<p>Transfer(address to,uint256 amount,uint256 nonce,uint256 deadline)<\/p>\n<p>Hashing this definition produces the typeHash:<\/p>\n<p>keccak256(<br \/>  &#8220;Transfer(address to,uint256 amount,uint256 nonce,uint256 deadline)&#8221;<br \/>)<\/p>\n<p>The message\u2019s structHash is then calculated from the type hash and encoded field\u00a0values.<\/p>\n<p>Conceptually:<\/p>\n<p>keccak256(<br \/>    abi.encode(<br \/>        TRANSFER_TYPEHASH,<br \/>        to,<br \/>        amount,<br \/>        nonce,<br \/>        deadline<br \/>    )<br \/>)<\/p>\n<p>The domain is hashed using the same EIP-712 struct-hashing rules to produce the domain separator. Finally, the message struct hash and domain separator are combined into one\u00a0digest.<\/p>\n<p>This digest\u200a\u2014\u200anot JSON.stringify(value)\u200a\u2014\u200ais what ultimately gets\u00a0signed.<\/p>\n<p>That distinction is the reason JavaScript and Solidity can independently reconstruct exactly the same\u00a0value.<\/p>\n<p>At this point, the full EIP-712 flow looks like\u00a0this:<\/p>\n<h3>Signing EIP-712 Typed Data in JavaScript<\/h3>\n<p>With ethers v6, the high-level API is signer.signTypedData(domain, types, value). ethers handles the EIP-712 encoding and hashing internally.<\/p>\n<p>A browser example can stay\u00a0compact:<\/p>\n<p>import { BrowserProvider } from &#8220;ethers&#8221;;<\/p>\n<p>const provider = new BrowserProvider(window.ethereum);<br \/>const signer = await provider.getSigner();<br \/>const chainId = (await provider.getNetwork()).chainId;<br \/>const domain = {<br \/>  name: &#8220;ExampleApp&#8221;,<br \/>  version: &#8220;1&#8221;,<br \/>  chainId,<br \/>  verifyingContract: &#8220;0x1234567890123456789012345678901234567890&#8221;,<br \/>};<br \/>const types = {<br \/>  Transfer: [<br \/>    { name: &#8220;to&#8221;, type: &#8220;address&#8221; },<br \/>    { name: &#8220;amount&#8221;, type: &#8220;uint256&#8221; },<br \/>    { name: &#8220;nonce&#8221;, type: &#8220;uint256&#8221; },<br \/>    { name: &#8220;deadline&#8221;, type: &#8220;uint256&#8221; },<br \/>  ],<br \/>};<br \/>const value = {<br \/>  to: &#8220;0x0000000000000000000000000000000000000000&#8221;,<br \/>  amount: 100_000_000n, \/\/ 100 USDC with 6 decimals<br \/>  nonce: 42n,<br \/>  deadline: BigInt(Math.floor(Date.now() \/ 1000) + 3600),<br \/>};<br \/>const signature = await signer.signTypedData(<br \/>  domain,<br \/>  types,<br \/>  value<br \/>);<\/p>\n<p>Notice that the frontend does not manually hash individual fields. That is intentional.<\/p>\n<p>Unless you are implementing infrastructure specifically around EIP-712 encoding, use the library implementation instead of recreating the algorithm yourself.<\/p>\n<p>The important part is that <strong>the frontend schema must exactly match the Solidity\u00a0schema<\/strong>.<\/p>\n<h3>Verifying an EIP-712 Signature in\u00a0Solidity<\/h3>\n<p>On-chain, OpenZeppelin\u2019s EIP712 contract reconstructs the domain-aware digest, while ECDSA performs signer recovery. OpenZeppelin explicitly documents the _hashTypedDataV4(structHash) plus ECDSA.recover pattern.<\/p>\n<p>Here is the Solidity side of the same\u00a0example:<\/p>\n<p>\/\/ SPDX-License-Identifier: MIT<br \/>pragma solidity ^0.8.20;<\/p>\n<p>import {EIP712} from<br \/>    &#8220;@openzeppelin\/contracts\/utils\/cryptography\/EIP712.sol&#8221;;<br \/>import {ECDSA} from<br \/>    &#8220;@openzeppelin\/contracts\/utils\/cryptography\/ECDSA.sol&#8221;;<br \/>contract TransferAuthorizer is EIP712 {<br \/>    struct Transfer {<br \/>        address to;<br \/>        uint256 amount;<br \/>        uint256 nonce;<br \/>        uint256 deadline;<br \/>    }<br \/>    bytes32 private constant TRANSFER_TYPEHASH =<br \/>        keccak256(<br \/>            &#8220;Transfer(address to,uint256 amount,uint256 nonce,uint256 deadline)&#8221;<br \/>        );<br \/>    mapping(address =&gt; mapping(uint256 =&gt; bool))<br \/>        public usedNonces;<br \/>    constructor() EIP712(&#8220;ExampleApp&#8221;, &#8220;1&#8221;) {}<br \/>    function authorizeTransfer(<br \/>        address expectedSigner,<br \/>        Transfer calldata transfer,<br \/>        bytes calldata signature<br \/>    ) external {<br \/>        require(<br \/>            block.timestamp &lt;= transfer.deadline,<br \/>            &#8220;Authorization expired&#8221;<br \/>        );<br \/>        require(<br \/>            !usedNonces[expectedSigner][transfer.nonce],<br \/>            &#8220;Nonce already used&#8221;<br \/>        );<br \/>        bytes32 structHash = keccak256(<br \/>            abi.encode(<br \/>                TRANSFER_TYPEHASH,<br \/>                transfer.to,<br \/>                transfer.amount,<br \/>                transfer.nonce,<br \/>                transfer.deadline<br \/>            )<br \/>        );<br \/>        bytes32 digest = _hashTypedDataV4(structHash);<br \/>        address recoveredSigner =<br \/>            ECDSA.recover(digest, signature);<br \/>        require(<br \/>            recoveredSigner == expectedSigner,<br \/>            &#8220;Invalid signature&#8221;<br \/>        );<br \/>        usedNonces[expectedSigner][transfer.nonce] = true;<br \/>        \/\/ Execute the authorized business operation here.<br \/>    }<br \/>}<\/p>\n<p>_hashTypedDataV4 combines the message struct hash with the contract&#8217;s EIP-712 domain separator and produces the final digest expected by the signature verification logic.<\/p>\n<p>Using OpenZeppelin is preferable to manually maintaining EIP-712 domain and ECDSA recovery code. Apart from reducing code, it avoids subtle mistakes around domain construction and signature handling.<\/p>\n<p>One additional production nuance: ECDSA.recover verifies signatures from EOAs. If your application must support smart contract wallets, account abstraction, or multisigs, you should also account for ERC-1271-style contract signatures rather than assuming every signer has an ECDSA private\u00a0key.<\/p>\n<h3>EIP-712 Does Not Prevent Replays for\u00a0You<\/h3>\n<p>EIP-712 gives you deterministic structured signing and domain separation. It does <strong>not<\/strong> automatically make an authorization single-use.<\/p>\n<p>Consider this perfectly valid signature:<\/p>\n<p>Alice authorizes transfer X<\/p>\n<p>If the contract accepts it today and nothing in contract state marks it as consumed, an attacker may simply submit the same authorization again.<\/p>\n<p>That is why production messages commonly contain a\u00a0nonce:<\/p>\n<p>uint256 nonce;<\/p>\n<p>and the contract consumes\u00a0it:<\/p>\n<p>mapping(address =&gt; mapping(uint256 =&gt; bool))<br \/>    public usedNonces;<\/p>\n<p>A deadline prevents an authorization from remaining usable indefinitely.<\/p>\n<p>Meanwhile, chainId and verifyingContract provide domain-level protection against signatures being reused in different EIP-712\u00a0domains.<\/p>\n<p>These controls solve related but different problems:<\/p>\n<p>nonce              -&gt; prevents repeated execution<br \/>deadline           -&gt; limits validity in time<br \/>chainId            -&gt; binds the domain to a chain<br \/>verifyingContract  -&gt; binds the domain to a contract<\/p>\n<p>The core rule is\u00a0simple:<\/p>\n<p><strong><em>A valid signature is not necessarily a valid authorization forever.<\/em><\/strong><\/p>\n<p>Signature verification proves who signed a digest. Your contract still decides whether that authorization is currently acceptable.<\/p>\n<h3>Common EIP-712 Mistakes in Production<\/h3>\n<p>Most EIP-712 bugs are not cryptography bugs. They are schema or authorization bugs.<\/p>\n<p><strong>1. Different field order between frontend and Solidity.<\/strong><br \/>Transfer(address to,uint256 amount,&#8230;) must match exactly on both\u00a0sides.<\/p>\n<p><strong>2. Type mismatches.<\/strong><br \/>uint256, address, bytes32, and string are not interchangeable representations.<\/p>\n<p><strong>3. Different domain name or version.<\/strong><br \/>ExampleApp version 1 and ExampleApp version 2 intentionally produce different domains.<\/p>\n<p><strong>4. Missing nonce.<\/strong><br \/>A correctly signed authorization may be replayable if contract state does not consume something unique.<\/p>\n<p><strong>5. Missing deadline.<\/strong><br \/>Without expiration, an unused signature may remain actionable much longer than intended.<\/p>\n<p><strong>6. Using <\/strong><strong>abi.encodePacked for the struct hash.<\/strong><br \/>The standard EIP-712 struct encoding corresponds to the ABI-encoded values; OpenZeppelin&#8217;s documented pattern uses abi.encode.<\/p>\n<p><strong>7. Incorrect domain assumptions around deployments or proxies.<\/strong><br \/>Your frontend must sign against the same effective EIP-712 domain the verification contract reconstructs. Contract addresses, chain changes, and domain-version changes should be treated as protocol changes, not frontend\u00a0details.<\/p>\n<h3>Conclusion<\/h3>\n<p>EIP-712 turns Ethereum signatures from loosely defined byte-string signing into deterministic, typed, domain-separated authorization.<\/p>\n<p>The flow is straightforward once the layers are separated:<\/p>\n<p>JavaScript object<br \/>    \u2193<br \/>typed EIP-712 structure<br \/>    \u2193<br \/>domain separator + struct hash<br \/>    \u2193<br \/>EIP-712 digest<br \/>    \u2193<br \/>wallet signature<br \/>    \u2193<br \/>Solidity rebuilds digest<br \/>    \u2193<br \/>recover signer<br \/>    \u2193<br \/>apply nonce\/deadline\/business rules<\/p>\n<p>The standard handles <strong>how structured data becomes a signable digest<\/strong>. Your application still owns <strong>what that signature authorizes, when it expires, and whether it has already been\u00a0used<\/strong>.<\/p>\n<p>That separation is the key to implementing EIP-712 correctly in production.<\/p>\n<p><a href=\"https:\/\/medium.com\/coinmonks\/eip-712-explained-sign-and-verify-typed-data-with-ethers-js-and-solidity-ae48d94eddaf\">EIP-712 Explained: Sign and Verify Typed Data with ethers.js and Solidity<\/a> was originally published in <a href=\"https:\/\/medium.com\/coinmonks\">Coinmonks<\/a> on Medium, where people are continuing the conversation by highlighting and responding to this story.<\/p>","protected":false},"excerpt":{"rendered":"<p>Imagine your application wants a user to authorize an off-chain action: Transfer 100 USDCto 0x&#8230;nonce 42deadline &#8230; You could serialize that data into a string and ask the wallet to sign it. But then subtle questions appear: Which serialization is canonical? Is 100 a string or an integer? Which contract is allowed to consume the [&hellip;]<\/p>\n","protected":false},"author":0,"featured_media":214414,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2],"tags":[],"class_list":["post-214413","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-interesting"],"_links":{"self":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/214413"}],"collection":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=214413"}],"version-history":[{"count":0,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/214413\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/media\/214414"}],"wp:attachment":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=214413"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=214413"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=214413"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}