Get a Quote
Back To BlogHashing dynamic types in EIP-712 signatures | Solidity Tip of the Week

Hashing dynamic types in EIP-712 signatures | Solidity Tip of the Week

Did you know that failing to hash dynamic types in EIP-712 signatures can break compatibility with wallets and dApps? Invalid EIP-712 signatures will cause signature verification to fail, leading to transaction reverts and UX issues.

EIP-712 is a standard for signing and verifying typed structured data. It secures interactions between smart contracts and off-chain systems while improving user experience. It also enhances security by making signed data easier for users to read. A key aspect of this standard is handling dynamic types like bytes and string. EIP712 requires these types to be hashed first using keccak256, reducing them to a fixed 32-byte word. Learn more here.

Here's an example contract that does not hash dynamic types into 32-byte words, making it non-compliant with EIP-712.

1// SPDX-License-Identifier: MIT
2pragma solidity 0.8.24;
3
4import {Coupon} from "./Coupon.sol";
5import {EIP712} from "./lib/openzeppelin-contracts/contracts/utils/cryptography/EIP712.sol";
6import {ECDSA} from "./lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol";
7
8contract Redeemer is EIP712 {
9    struct Redeem {
10        address receiver;
11        uint256 deadline;
12        bytes conditions;
13    }
14
15    bytes32 public constant REDEEM_TYPEHASH =
16        keccak256("Redeem(address receiver,uint256 deadline,uint256 nonce,bytes conditions)");
17
18    address public immutable COUPON;
19    address public immutable ISSUER;
20
21    mapping(address account => uint256 nonce) public nonces;
22
23    constructor(address coupon_, address issuer_) EIP712("Coupon Redeem Contract", "1") {
24        COUPON = coupon_;
25        ISSUER = issuer_;
26    }
27
28    function redeem(Redeem memory redeemData, uint8 v_, bytes32 r_, bytes32 s_) external returns (uint256) {
29        require(msg.sender == redeemData.receiver, "Coupon can only be redeemed by the receiver");
30        require(block.timestamp <= redeemData.deadline, "Coupon redeem deadline expired");
31
32        bytes32 structHash = hashStruct(redeemData);
33        bytes32 digest = hashTypedData(structHash);
34
35        address signer = ECDSA.recover(digest, v_, r_, s_);
36        require(signer == ISSUER, "Coupon can only be redeemed if signed by issuer");
37
38        nonces[msg.sender]++;
39        return Coupon(COUPON).mint(msg.sender);
40    }
41
42    function hashStruct(Redeem memory redeemData) public view returns (bytes32) {
43        return keccak256(
44            abi.encode(
45                REDEEM_TYPEHASH, 
46                redeemData.receiver, 
47                redeemData.deadline, 
48                nonces[msg.sender], 
49                redeemData.conditions
50            )
51        );
52    }
53
54    function hashTypedData(bytes32 structHash) public view returns (bytes32) {
55        return _hashTypedDataV4(structHash);
56    }
57}

In the above contract, the issuer (e.g., backend server) will sign an off-chain message (e.g., coupon) and send it to the user. The user will then try to redeem it by calling the redeem() function. However, the coupon will fail to be redeemed because the digest signed off-chain won’t match the digest generated on-chain, causing signature verification to fail. This is because the digest signed off-chain will most likely be generated using EIP-712 compliant tools that properly hash dynamic types.

The following Foundry test demonstrates the issue caused by not hashing dynamic types into 32-byte words. It simulates a scenario where the issuer signs an off-chain message using EIP-712 compliant hashing, while the on-chain contract fails to hash the coupon contents. As a result, the signed digest does not match the one generated on-chain, causing the redeem() function to revert due to failed signature validation.

1// SPDX-License-Identifier: MIT
2pragma solidity 0.8.24;
3
4import {Test, console} from "./lib/forge-std/src/Test.sol";
5import {Redeemer} from "./src/Redeemer.sol";
6import {Coupon} from "./src/Coupon.sol";
7
8contract RedeemerTest is Test {
9    Coupon internal coupon;
10    Redeemer internal redeemer;
11
12    Account internal issuer = makeAccount("issuer");
13    Account internal receiver = makeAccount("receiver");
14
15    function setUp() public virtual {
16        coupon = new Coupon();
17        redeemer = new Redeemer(address(coupon), issuer.addr);
18    }
19
20    function test_redeem_fails_due_to_eip712_noncompliant() public {
21        // Issuer creates the redeem data defining the receiver, deadline and conditions of the coupon.
22        Redeemer.Redeem memory redeemData = Redeemer.Redeem({
23            receiver: receiver.addr,
24            deadline: block.timestamp + 1 days,
25            conditions: "30% discount on all goods"
26        });
27
28        // Issuer constructs the struct hash according to EIP-712 by hashing dynamic data to a 32-byte word.
29        bytes32 structHash = keccak256(
30            abi.encode(
31                redeemer.REDEEM_TYPEHASH(),
32                redeemData.receiver,
33                redeemData.deadline,
34                redeemer.nonces(receiver.addr),
35                keccak256(redeemData.conditions)
36            )
37        );
38
39        // Issuer constructs the message digest according to EIP-712.
40        bytes32 digest = redeemer.hashTypedData(structHash);
41
42        // Issuer signs the digest.
43        (uint8 v, bytes32 r, bytes32 s) = vm.sign(issuer.key, digest);
44
45        // Receiver tries to redeem the coupon.
46        vm.startPrank(receiver.addr);
47
48        // The digest signed by the issuer won't match the digest generated on-chain.
49        vm.expectRevert("Coupon can only be redeemed if signed by issuer");
50        redeemer.redeem(redeemData, v, r, s);
51    }
52}

To fix this issue hash dynamic types into 32-byte words before encoding them. The following change will ensure compatibility with EIP-712 compliant wallets and tools.

1function hashStruct(Redeem memory redeemData) public view returns (bytes32) {
2    return keccak256(
3        abi.encode(
4            REDEEM_TYPEHASH,
5            redeemData.receiver,
6            redeemData.deadline,
7            nonces[msg.sender],
8-           redeemData.conditions,
9+           keccak256(redeemData.conditions)
10        )
11    );
12}
Previous ArticleNext Article
Hashing dynamic types in EIP-712 signatures | Solidity Tip of the Week | Coverage Labs