Important: This guide serves as a practical implementation and "add-on" to the following Cyfrin blog posts:

Understanding Ethereum Signature Standards: EIP-191 & EIP-712

ECDSA and Digital Signatures Deep Dive

I highly recommend reading those articles first to grasp the underlying concepts. This guide is a straightforward, "put into practice" manual designed to help you implement those theoretical foundations.

Hi!! In this guide, we are going to walk through a complete, runnable Foundry test that demonstrates how to generate a signature (off-chain) and verify it on-chain using a custom Verifier contract.

1. Defining our Schema

First, we need to define the structures we are working with. EIP-712 is all about "Typed Data," so we define our DomainSeparator (the context) and our Message (the actual data we want to sign).

struct DomainSeparator {
    string name;
    string version;
    uint256 chainId;
    address verifyingContract;
}

struct Message {
    string name;
    string favColor;
    uint256 favNumber;
}

2. Setting up the Typehashes

The TYPEHASH is the keystone of EIP-712. It ensures that the signer and the verifier are looking at the exact same data structures. We calculate these by taking the keccak256 hash of the type string. NOTE that there are no spaces after the commas! (I had expend hours debugging this xd).

bytes32 typehashDomainSeparator = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
bytes32 typehashMessage = keccak256("Message(string name,string favColor,uint256 favNumber)");

3. Hashing the Data (The hashStruct)

This is where many developers trip up. For atomic types like uint256 or address, we encode them directly. However, for dynamic types like string, we must hash the contents first: keccak256(bytes(stringData)). This converts variable-length data into a predictable 32-byte word.

bytes32 message = keccak256(
    abi.encode(
        typehashMessage,
        keccak256(bytes(dataMessage.name)),
        keccak256(bytes(dataMessage.favColor)),
        dataMessage.favNumber
    )
);

4. The Final Digest Construction

To prevent replay attacks and ensure the signature is only valid for this contract on this specific chain, we combine a fixed prefix (0x1901), the domainSeparator, and our message hash. We use abi.encodePacked because we want to concatenate these bytes tightly without the 32-byte padding that abi.encode would add.

bytes1 prefix = bytes1(0x19);
bytes1 version = bytes1(0x01);
bytes32 digest = keccak256(abi.encodePacked(prefix, version, domainSeparator, message));

5. The Full Implementation

Here is our complete Proof of Concept. We use Foundry's vm.sign to simulate a user (Alice) signing the data, and then we pass that data into our Verifier contract to prove that the address we recover matches Alice's address.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console, Vm} from "forge-std/Test.sol";

struct DomainSeparator {
    string name;
    string version;
    uint256 chainId;
    address verifyingContract;
}

struct Message {
    string name;
    string favColor;
    uint256 favNumber;
}

contract PlayingWithSignatures is Test {
    function setUp() public {}

    function test_signatures() public {
        // SETUP
        Verifier verifier = new Verifier();
        
        // 1. Setup the signer
        (address alice, uint256 alicePk) = makeAddrAndKey("alice");

        // 2. Prepare the digest
        bytes1 prefix = bytes1(0x19);
        bytes1 version = bytes1(0x01);

        bytes32 typehashDomainSeparator = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
        DomainSeparator memory dataDomainSeparator = DomainSeparator({
            name: "VerifierContract",
            version: "1.0.0",
            chainId: block.chainid,
            verifyingContract: address(verifier)  
        }); 
        
        bytes32 domainSeparator = keccak256(
            abi.encode(
                    typehashDomainSeparator, 
                    keccak256(bytes(dataDomainSeparator.name)),
                    keccak256(bytes(dataDomainSeparator.version)),
                    dataDomainSeparator.chainId,
                    dataDomainSeparator.verifyingContract
                )
            );

        bytes32 typehashMessage = keccak256("Message(string name,string favColor,uint256 favNumber)");
        Message memory dataMessage = Message({
            name: "Cthultu",
            favColor: "pink",
            favNumber: 73
        });

        bytes32 message = keccak256(
            abi.encode(
                typehashMessage,
                keccak256(bytes(dataMessage.name)),
                keccak256(bytes(dataMessage.favColor)),
                dataMessage.favNumber
            )
        );

        bytes32 digest = keccak256(abi.encodePacked(prefix, version, domainSeparator, message));

        // 3. Generate the signature components
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, digest);

        // 4. Verification
        address result = verifier.getSigner(dataMessage, v,r,s);
        assertEq(alice, result);
    }
}

contract Verifier {
    function getSigner(Message memory message, uint8 v, bytes32 r, bytes32 s) public view returns (address signer) {
        bytes1 prefix = bytes1(0x19);
        bytes1 version = bytes1(0x01);

        bytes32 typehashDomainSeparator = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
        DomainSeparator memory dataDomainSeparator = DomainSeparator({name: "VerifierContract",version: "1.0.0",chainId: block.chainid,verifyingContract: address(this) }); 
        bytes32 domainSeparator = keccak256(abi.encode(typehashDomainSeparator, keccak256(bytes(dataDomainSeparator.name)),keccak256(bytes(dataDomainSeparator.version)),dataDomainSeparator.chainId,dataDomainSeparator.verifyingContract));

        bytes32 typehashMessage = keccak256("Message(string name,string favColor,uint256 favNumber)");
        bytes32 hashedMessage = keccak256(abi.encode(typehashMessage,keccak256(bytes(message.name)),keccak256(bytes(message.favColor)),message.favNumber));

        bytes32 digest = keccak256(abi.encodePacked(prefix, version, domainSeparator, hashedMessage));
        signer = ecrecover(digest, v, r, s);
    }
}

Now, here below is a JS script that generates a signature (I'd like to add this because normally you generate the signatures off-chain from a web app, server, etc). So, you can generate a signature with this, and then hardcode the returned v,r,s component on the aboves test suite, and it should work perfectly well

EXTRA

Here below is a JS script that generates a signature (I'd like to add this because normally you generate the signatures off-chain from a web app, server, etc). So, as a practice, you can generate a signature with this, and then hardcode the returned v, r, s components on the above test suite, and it should work perfectly well.

In this script, we use ethers.js (v6) and you can run it with nodeJS or similars. The key method is signer.signTypedData, which handles the EIP-712 hashing logic automatically (prefix, domain separator, and struct hash) so we don't have to manually concatenate bytes.

const { Wallet, Signature } = require("ethers");

async function generateSignature() {
    // This should match the 'alice' mnemonic/private key in your Foundry test
    // You can console.log such value and then paste it here
    const privateKey = "PrivK"; // Replace with your private key
    const wallet = new Wallet(privateKey);

    // 1. Define the Domain (Must match the Verifier contract exactly)
    // In reality, normally you can fetch this from the target contract, it normally is a public getter function, a public variable, etc
    const domain = {
        name: "VerifierContract",
        version: "1.0.0",
        chainId: 31337, // Default Anvil/Foundry chainId
        verifyingContract: "0x..." // Replace with your deployed/mocked contract address (again, you can console.log it and paste it here
    };

    // 2. Define the Types (The structure of your message)
    const types = {
        Message: [
            { name: "name", type: "string" },
            { name: "favColor", type: "string" },
            { name: "favNumber", type: "uint256" }
        ]
    };

    // 3. The actual data we want Alice to sign
    const message = {
        name: "Cthultu",
        favColor: "pink",
        favNumber: 73
    };

    // 4. Sign the data
    const rawSignature = await wallet.signTypedData(domain, types, message);
    
    // 5. Split the signature into v, r, s
    const sig = Signature.from(rawSignature);

    console.log("--- Signature Components ---");
    console.log("v:", sig.v);
    console.log("r:", sig.r);
    console.log("s:", sig.s);
}

generateSignature();

That's all for today's "lab" session. Now head back to the Batcave, and keep hacking!!🦇 Thanks for reading ❤