Sign Up
Skip to content

Verified Typed Data & Validated Signature Messages On-chain

Time to complete: 10-20 minutes

In this guide we'll walk you through how to use provided source code for an EVM based contract and explain the inner workings of a custom on-chain message verifier contract that composes the Universal Signature Validator (ERC-6492), and how to perform in-app signature verification with typed data.

This can be accomplished with 6 steps:

  1. Create a Builder Project & Obtain an Access Key
  2. Initialize React Vite Application
  3. Use Sequence Wallet for User Sign in
  4. Use EIP712 Typed Data to Generate EIP6492 Signatures
  5. Deploy Contract for EIP712 Verification and EIP1271 Validation
  6. Render Response from Verifying & Validating Contract

The general flow for this application can be seen in the following sequence diagram:

sequence flow for eip712 eip1271 verfying and validating signatures

1. Create a Builder Project & Obtain an Access Key

First, follow this guide to create a project in the Sequence Builder and obtain a project access key.

2. Initialize React Vite Application

Next, begin by initializing a new project that will hold all the code neccessary to generate signatures and validation responses from the blockchain:

pnpm create vite

This should create a blank project that you can start adding elements and logic to.

3. Use Sequence Wallet for User Sign in

Install the necessary packages required for the project to function:

pnpm install 0xsequence ethers

Then enable a user to sign in with on your chosen network and the obtained project access key from step 1.

 
import { sequence } from '0xsequence'
 
function App() {
    sequence.initWallet(PROJECT_ACCESS_KEY, {
        defaultNetwork: 'sepolia',
    });
 
    const signIn = async () => {
        const wallet = sequence.getWallet()
        const details = await wallet.connect({app: 'sequence signature validation demo'})
 
        if(details){
            console.log('is signed in')
            console.log(details)
        }
    }
 
    return (
        ...
        <button onClick={() => signIn()}>sign in</button>
        ...
    )
}

4. Use EIP712 Typed Data to Generate EIP6492 Signatures

Next, we'll define a custom typed data in typescript and using the utilities library from Sequence constructing a TypedData type, where we will be verifying a message structure with name, wallet, and message parameters:

In this example VERIFYING_CONTRACT_ADDRESS is the smart contract we deployed on sepolia but we will show you in the next step what this contract does so you can deploy yourself any any network:

import { sequence } from '0xsequence'
 
interface Person {
  name: string;
  wallet: string;
  message: string;
}
 
const VERIFYING_CONTRACT_ADDRESS = '0xB81efF8d6700b83B24AA69ABB18Ca8f9F7A356c5'
const CHAIN_ID = 11155111
 
const submitSignature = () => {
    const wallet = sequence.getWallet()
 
    const message = 'hey' // message can be dynamic
    const person: Person = {
        name: "user", // name can be dynamic
        wallet: wallet.getAddress(),
        message: message,
    };
 
    const chainId = CHAIN_ID
    const typedData: sequence.utils.TypedData = {
        domain: {
            // Domain settings must match verifying contract
            name: "Sequence Signature Validation Demo",
            version: "1",
            chainId,
            verifyingContract: VERIFYING_CONTRACT_ADDRESS,
        },
        types: {
            Person: [
                { name: "name", type: "string" },
                { name: "wallet", type: "address" },
                { name: "message", type: "string" },
            ],
        },
        message: person,
        primaryType: "Person",
    };
    ...
}

Then we will sign the type message object with the various referenced properties:

const wallet = await sequence.getWallet()
const signer = wallet.getSigner(CHAIN_ID);
 
const signature = await signer.signTypedData(
    typedData.domain,
    typedData.types,
    typedData.message,
    {
        chainId,
        eip6492: true, // enabling signatures for non-deployed wallet contracts
    }
);
 
console.log("signature", signature);

Great, attach the function to a button and see the signature be generated after a user has clicked the button:

<button onClick={() => submitSignature()}>verify signature</button>

5. Deploy Contract for EIP712 Verification and EIP1271 Validation

We will now provide source code that you can use in something like Remix to deploy a contract, or even something like Foundry for building and deploying with the Sequence Builder

Universal Signature Validator

The Universal Signature Validator can hypothetically be deployed once for a specific network, and shared with many applications, making it composable and reusable. It's use is for both off-chain and on-chain smart contract wallets for EIP6492 enabled wallets.

You can find the source code here that you can use to deploy.

Custom Contract Verifier

The next contract we'll explain more in-depth with the various functions, as this contract can be customized for the specific application. Begin with the following basics with the Universal Signature Validator passed in the constructor in the first step:

import {IERC1271} from "./interfaces/IERC1271.sol";
import {IERC6492} from "./interfaces/IERC6492.sol";
 
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
 
struct Person { // can be customized
    string name;
    address wallet;
    string message;
}
 
contract EIP712Verifier is EIP712 {
    using ECDSA for bytes32;
 
    IERC6492 public immutable ERC6492_SIGNATURE_VALIDATOR; // the universal signature validator
 
    bytes32 private constant _ERC6492_DETECTION_SUFFIX = 0x6492649264926492649264926492649264926492649264926492649264926492;
 
    // this line of code must be customized to the struct you're verifying
    bytes32 private constant _PERSON_TYPEHASH = keccak256(bytes("Person(string name,address wallet,string message)"));
 
    constructor(address erc6492SignatureValidator) {
        ERC6492_SIGNATURE_VALIDATOR = IERC6492(erc6492SignatureValidator);
    }
    ...
}

Verify Signature

Next, we have the verify signature function which both: creates the message hash digest and validates the signer:

/// @dev Verifies the signature of a person.
function verifySignature(address signer, Person memory person, bytes calldata signature)
    external
    returns (bool success)
{
    bytes32 digest = personDigest(person);
    return ERC6492_SIGNATURE_VALIDATOR.isValidSig(signer, digest, signature);
}

Custom Person Digest

In the following function we recreate the struct hash with the passed in parameters, which can be extended to include more or less parameters with varying types:

For more information on constructing the digest, see the EIP712 specification.

/// @dev Returns the EIP712 hash of a person.
function personDigest(Person memory person) public view returns (bytes32 digest) {
    bytes32 structHash = keccak256(
        abi.encode(_PERSON_TYPEHASH, keccak256(bytes(person.name)), person.wallet, keccak256(bytes(person.message)))
    );
    digest = EIP712._hashTypedDataV4(structHash);
}

Validate Signer

Next, we validate the signer address, the digest and signature. If an EIP6492 signature has been supplied we use the universal signature validator, otherwise we check the EIP1271 signature validation directly:

/// @dev Validates the ERC1271 signature of a signer.
function validateSigner(address signer, bytes32 digest, bytes calldata signature) internal returns (bool success) {
    if (signature.length >= 32) {
        bool isCounterfactual =
            bytes32(signature[signature.length - 32:signature.length]) == _ERC6492_DETECTION_SUFFIX;
        if (isCounterfactual) {
            return ERC6492_SIGNATURE_VALIDATOR.isValidSig(signer, digest, signature);
        }
    }
 
    try IERC1271(signer).isValidSignature(digest, signature) returns (bytes4 magicValue) {
        return magicValue == IERC1271.isValidSignature.selector;
    } catch {}
    return false;
}

You're ready to deploy both contracts, make sure to choose your network.

6. Render Response from Verifying & Validating Contract

We pass the signatures and call the contract deployed using ethers with the passed in PROJECT_ACCESS_KEY in the following steps:

Create a Provider

Create a provider using the project access key:

import { ethers } from 'ethers'
 
const CHAIN_HANDLE = 'sepolia'
 
const provider = new ethers.JsonRpcProvider(
    `https://nodes.sequence.app/${CHAIN_HANDLE}/${PROJECT_ACCESS_KEY}`
);

Initialize an Ethers Contract

Import the ABI that was generated from step 5 (or copy from the git source code), include the provider, and input the verifying contract address:

import { ABI } from "./abi";
 
const contract = new ethers.Contract(
    VERIFYING_CONTRACT_ADDRESS,
    ABI,
    provider
);

Static Call the Verify Signature Function

By performing a static call on the function, we simulate the transaction without submitting it on chain. This returns a result specifying if the validation was true or false:

const address = await wallet.getAddress()
 
const person: Person = {
    name: "user",
    wallet: address,
    message: message,
}
 
const signature = await signer.signTypedData(
    typedData.domain,
    typedData.types,
    typedData.message,
    {
        chainId,
        eip6492: true,
    }
);
 
const result = await contract.verifySignature.staticCall(
    address,
    person,
    signature
);
 
console.log(`Signature is ${result ? "valid" : "invalid"}`);
return result;

Conclusion

Now that we have message structs being passed to a blockchain and messages verified with their inputs, we can extend the application to a multitude of use cases that ensure users are signing the right information (e.g. permitting to spend ERC20's, perform off-chain bidding, QR code containing signature approved minting, etc.).