SNIP12 and Typed Messages

Similar to EIP712, SNIP12 is a standard for secure off-chain signature verification on Starknet. It provides a way to hash and sign generic typed structs rather than just strings. When building decentralized applications, usually you might need to sign a message with complex data. The purpose of signature verification is then to ensure that the received message was indeed signed by the expected signer, and it hasn’t been tampered with.

OpenZeppelin Contracts for Cairo provides a set of utilities to make the implementation of this standard as easy as possible, and in this guide we will walk you through the process of generating the hashes of typed messages using these utilities for on-chain signature verification. For that, let’s build an example with a custom ERC20 contract adding an extra transfer_with_signature method.

This is an educational example, and it is not intended to be used in production environments.

CustomERC20

Let’s start with a basic ERC20 contract leveraging the ERC20Component, and let’s add the new function. Note that some declarations are omitted for brevity. The full example will be available at the end of the guide.

#[starknet::contract]
mod CustomERC20 {
    use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
    use starknet::ContractAddress;

    component!(path: ERC20Component, storage: erc20, event: ERC20Event);

    #[abi(embed_v0)]
    impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
    impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;

    (...)

    #[constructor]
    fn constructor(
        ref self: ContractState,
        initial_supply: u256,
        recipient: ContractAddress
    ) {
        self.erc20.initializer("MyToken", "MTK");
        self.erc20.mint(recipient, initial_supply);
    }

    #[external(v0)]
    fn transfer_with_signature(
        ref self: ContractState,
        recipient: ContractAddress,
        amount: u256,
        nonce: felt252,
        expiry: u64,
        signature: Array<felt252>
    ) {
        (...)
    }
}

The transfer_with_signature function will allow a user to transfer tokens to another account by providing a signature. The signature will be generated off-chain, and it will be used to verify the message on-chain. Note that the message we need to verify is a struct with the following fields:

  • recipient: The address of the recipient.

  • amount: The amount of tokens to transfer.

  • nonce: A unique number to prevent replay attacks.

  • expiry: The timestamp when the signature expires.

Note that generating the hash of this message on-chain is a requirement to verify the signature, because if we accept the message as a parameter, it could be easily tampered with.

Generating the Typed Message Hash

To generate the hash of the message, we need to follow these steps:

1. Define the message struct.

In this particular example, the message struct looks like this:

struct Message {
    recipient: ContractAddress,
    amount: u256,
    nonce: felt252,
    expiry: u64
}

2. Get the message type hash.

This is the starknet_keccak(encode_type(message)) as defined in the SNIP.

In this case it can be computed as follows:

let message_type_hash = selector!(
    "\"Message\"(\"recipient\":\"ContractAddress\",\"amount\":\"u256\",\"nonce\":\"felt\",\"expiry\":\"u64\")\"u256\"(\"low\":\"felt\",\"high\":\"felt\")"
);

which is the same as:

let message_type_hash = 0x120ae1bdaf7c1e48349da94bb8dad27351ca115d6605ce345aee02d68d99ec1;
In practice it’s better to compute the type hash off-chain and hardcode it in the contract, since it is a constant value.

3. Implement the StructHash trait for the struct.

You can import the trait from: openzeppelin::utils::snip12::StructHash. And this implementation is nothing more than the encoding of the message as defined in the SNIP.

use openzeppelin::utils::snip12::StructHash;

use core::hash::HashStateExTrait;
use hash::{HashStateTrait, Hash};
use poseidon::PoseidonTrait;
use starknet::ContractAddress;

const MESSAGE_TYPE_HASH: felt252 =
    0x120ae1bdaf7c1e48349da94bb8dad27351ca115d6605ce345aee02d68d99ec1;

#[derive(Copy, Drop, Hash)]
struct Message {
    recipient: ContractAddress,
    amount: u256,
    nonce: felt252,
    expiry: u64
}

impl StructHashImpl of StructHash<Message> {
    fn hash_struct(self: @Message) -> felt252 {
        let hash_state = PoseidonTrait::new();
        hash_state.update_with(MESSAGE_TYPE_HASH).update_with(*self).finalize()
    }
}

4. Implement the SNIP12Metadata trait.

This implementation determines the values of the domain separator. Only the name and version fields are required because the chain_id is obtained on-chain, and the revision is hardcoded to 1.

use openzeppelin::utils::snip12::SNIP12Metadata;

impl SNIP12MetadataImpl of SNIP12Metadata {
    fn name() -> felt252 { 'DAPP_NAME' }
    fn version() -> felt252 { 'v1' }
}

In the above example, no storage reads are required which avoids unnecessary extra gas costs, but in some cases we may need to read from storage to get the domain separator values. This can be accomplished even when the trait is not bounded to the ContractState, like this:

use openzeppelin::utils::snip12::SNIP12Metadata;

impl SNIP12MetadataImpl of SNIP12Metadata {
    fn name() -> felt252 {
        let state = unsafe_new_contract_state();

        // Some logic to get the name from storage
        state.erc20.name().at(0).unwrap().into()
    }

    fn version() -> felt252 { 'v1' }
}

5. Generate the hash.

The final step is to use the OffchainMessageHashImpl implementation to generate the hash of the message using the get_message_hash function. The implementation is already available as a utility.

use openzeppelin::utils::snip12::{SNIP12Metadata, StructHash, OffchainMessageHashImpl};

use core::hash::HashStateExTrait;
use hash::{HashStateTrait, Hash};
use poseidon::PoseidonTrait;
use starknet::ContractAddress;

const MESSAGE_TYPE_HASH: felt252 =
    0x120ae1bdaf7c1e48349da94bb8dad27351ca115d6605ce345aee02d68d99ec1;

#[derive(Copy, Drop, Hash)]
struct Message {
    recipient: ContractAddress,
    amount: u256,
    nonce: felt252,
    expiry: u64
}

impl StructHashImpl of StructHash<Message> {
    fn hash_struct(self: @Message) -> felt252 {
        let hash_state = PoseidonTrait::new();
        hash_state.update_with(MESSAGE_TYPE_HASH).update_with(*self).finalize()
    }
}

impl SNIP12MetadataImpl of SNIP12Metadata {
    fn name() -> felt252 { 'DAPP_NAME' }
    fn version() -> felt252 { 'v1' }
}

fn get_hash(
    account: ContractAddress,
    recipient: ContractAddress,
    amount: u256,
    nonce: felt252,
    expiry: u64
) -> felt252 {
    let message = Message {
        recipient,
        amount,
        nonce,
        expiry
    };
    message.get_message_hash(account)
}
The expected parameter for the get_message_hash function is the address of account that signed the message.

Full Implementation

Finally, the full implementation of the CustomERC20 contract looks like this:

We are using the DualCaseAccount to verify the signature, and the NoncesComponent to handle nonces to prevent replay attacks.
use openzeppelin::utils::snip12::{SNIP12Metadata, StructHash, OffchainMessageHashImpl};

use core::hash::HashStateExTrait;
use hash::{HashStateTrait, Hash};
use poseidon::PoseidonTrait;
use starknet::ContractAddress;

const MESSAGE_TYPE_HASH: felt252 =
    0x120ae1bdaf7c1e48349da94bb8dad27351ca115d6605ce345aee02d68d99ec1;

#[derive(Copy, Drop, Hash)]
struct Message {
    recipient: ContractAddress,
    amount: u256,
    nonce: felt252,
    expiry: u64
}

impl StructHashImpl of StructHash<Message> {
    fn hash_struct(self: @Message) -> felt252 {
        let hash_state = PoseidonTrait::new();
        hash_state.update_with(MESSAGE_TYPE_HASH).update_with(*self).finalize()
    }
}

#[starknet::contract]
mod CustomERC20 {
    use openzeppelin::account::dual_account::{DualCaseAccount, DualCaseAccountTrait};
    use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
    use openzeppelin::utils::cryptography::nonces::NoncesComponent;
    use starknet::ContractAddress;

    use super::{Message, OffchainMessageHashImpl, SNIP12Metadata};

    component!(path: ERC20Component, storage: erc20, event: ERC20Event);
    component!(path: NoncesComponent, storage: nonces, event: NoncesEvent);

    #[abi(embed_v0)]
    impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
    impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;

    #[abi(embed_v0)]
    impl NoncesImpl = NoncesComponent::NoncesImpl<ContractState>;
    impl NoncesInternalImpl = NoncesComponent::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        erc20: ERC20Component::Storage,
        #[substorage(v0)]
        nonces: NoncesComponent::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        ERC20Event: ERC20Component::Event,
        #[flat]
        NoncesEvent: NoncesComponent::Event
    }

    #[constructor]
    fn constructor(ref self: ContractState, initial_supply: u256, recipient: ContractAddress) {
        self.erc20.initializer("MyToken", "MTK");
        self.erc20.mint(recipient, initial_supply);
    }

    /// Required for hash computation.
    impl SNIP12MetadataImpl of SNIP12Metadata {
        fn name() -> felt252 {
            'CustomERC20'
        }
        fn version() -> felt252 {
            'v1'
        }
    }

    #[external(v0)]
    fn transfer_with_signature(
        ref self: ContractState,
        recipient: ContractAddress,
        amount: u256,
        nonce: felt252,
        expiry: u64,
        signature: Array<felt252>
    ) {
        assert(starknet::get_block_timestamp() <= expiry, 'Expired signature');
        let owner = starknet::get_caller_address();

        // Check and increase nonce
        self.nonces.use_checked_nonce(owner, nonce);

        // Build hash for calling `is_valid_signature`
        let message = Message { recipient, amount, nonce, expiry };
        let hash = message.get_message_hash(owner);

        let is_valid_signature_felt = DualCaseAccount { contract_address: owner }
            .is_valid_signature(hash, signature);

        // Check either 'VALID' or true for backwards compatibility
        let is_valid_signature = is_valid_signature_felt == starknet::VALIDATED
            || is_valid_signature_felt == 1;
        assert(is_valid_signature, 'Invalid signature');

        // Transfer tokens
        self.erc20._transfer(owner, recipient, amount);
    }
}