SNIP-12 Deep Dive: Type Hashing and Domain Separators
SNIP-12 is Starknet Improvement Proposal 12, which is heavily based on EIP712. Both proposals define a standard for hashing and signing typed structured data replacing the older approach of signing hexadecimal strings that are meaningless to users in browser extensions.
Having implemented SNIP-12 firsthand, I’d like to share my key learnings and discoveries.
I started with the OpenZeppelin docs, which provides a walkthrough for implementing signature signing in Cairo. The basic flow involves generating an off-chain signature in your preferred language (like Golang or Javascript) and then verifying that signature on-chain using Cairo.
This post focuses on the on-chain verification aspects. Let’s begin with the tool versions I used:
# scarb.toml
starknet = "2.8.2"
snforge_std = "0.30.0"
openzeppelin = "0.17.0"
go 1.23.2
require github.com/NethermindEth/starknet.go v0.7.3
Message Type Hash Generation: Lessons Learned
Let’s start with OpenZeppelin’s example of a message struct and its type hash:
const MESSAGE_TYPE_HASH: felt252 = 0xa2a7036c1f406af7c47722b209f23bd2f2d6ac21423c8c73bd92cf28409ee2;
#[derive(Copy, Drop, Hash)]
struct Message {
recipient: ContractAddress,
amount: u256,
nonce: felt252,
expiry: u128
}
This hash is generated using Cairo’s selector macro:
let message_type_hash = selector!(
"\"Message\"(\"recipient\":\"ContractAddress\",\"amount\":\"u256\",\"nonce\":\"felt\",\"expiry\":\"u64\")\"u256\"(\"low\":\"felt\",\"high\":\"felt\")"
);
However, when implementing this in other languages, you’ll encounter some challenges. For instance, trying to use u64
(as shown in the selector string) will result in errors like this in Golang1:
panic: fail to unmarshal TypedData: can't parse type u64
Through implementation, I discovered two critical points:
-
Type Compatibility: While the Cairo Message struct doesn’t need to exactly match SNIP-12’s data types, the
MESSAGE_TYPE_HASH
must remain consistent between off-chain and on-chain implementations. For maintainability, I would prefer to keep the types aligned. -
Timestamp vs u128: For the
expiry
field, you can use eitheru128
ortimestamp
. They’re functionally equivalent since timestamps are treated as u128 values representing seconds2. Usingtimestamp
makes the code’s intent clearer.
Here’s a working off-chain implementation in Golang that correctly generates the message type hash:
package main
import (
"encoding/json"
"fmt"
"github.com/NethermindEth/starknet.go/typedData"
)
func main() {
// JSON data defined directly in the code
jsonData := `{
"types": {
"StarknetDomain": [
{ "name": "name", "type": "shortstring" },
{ "name": "version", "type": "shortstring" },
{ "name": "chainId", "type": "shortstring" },
{ "name": "revision", "type": "shortstring" }
],
"Message": [
{ "name": "recipient", "type": "ContractAddress" },
{ "name": "amount", "type": "u256" },
{ "name": "nonce", "type": "felt" },
{ "name": "expiry", "type": "timestamp" }
]
},
"primaryType": "Message",
"domain": {
"name": "StarkNet Mail",
"version": "1",
"chainId": "0x534e5f5345504f4c4941",
"revision": "1"
},
"message": {
"recipient": "0xd392b0c0500700d02d27ab30805ec80ddd3320ff",
"amount": "100.00",
"nonce": 0,
"expiry": 1734859800
}
}`
var ttd typedData.TypedData
err := json.Unmarshal([]byte(jsonData), &ttd)
if err != nil {
panic(fmt.Errorf("fail to unmarshal TypedData: %w", err))
}
messageTypeHash, err := ttd.GetTypeHash("Message")
if err != nil {
panic(fmt.Errorf("fail to get message type hash: %w", err))
}
fmt.Println("message type hash:", messageTypeHash)
// message type hash: 0xa2a7036c1f406af7c47722b209f23bd2f2d6ac21423c8c73bd92cf28409ee2
}
https://goplay.tools/snippet/BnnMer-oVQu
After changing expiry
data type to timestamp
, you’ll need to update MESSAGE_TYPE_HASH
to match the new hash and the Message struct in Cairo to be compliant with SNIP-12.
-const MESSAGE_TYPE_HASH: felt252 = 0x120ae1bdaf7c1e48349da94bb8dad27351ca115d6605ce345aee02d68d99ec1;
+const MESSAGE_TYPE_HASH: felt252 = 0xa2a7036c1f406af7c47722b209f23bd2f2d6ac21423c8c73bd92cf28409ee2;
#[derive(Copy, Drop, Hash)]
struct Message {
recipient: ContractAddress,
amount: u256,
nonce: felt252,
- expiry: u64
+ expiry: u128
}
Starknet Domain Separator Mystery
Edit: I realise this is due to felt252 interpreted
'1'
as a string instead of integer. If the SNIP12MetadataImpl returns1
, the domain hash will match. On hindsight this looks so obvious and the following content after this edit is not relevant.pub impl SNIP12MetadataImpl of SNIP12Metadata { fn name() -> felt252 { 'Paradex' } fn version() -> felt252 { 1 // instead of previous '1' } }
Read more on felt252 here
An interesting discovery involves the Starknet Domain separator. When using revision 13, I found that the domain struct hash differs between Cairo and other languages. The culprit? A seemingly minor detail - the version field requires a ‘v’ prefix in Cairo to generate matching hashes.
"domain": {
"name": "StarkNet Mail",
"version": "1",
"chainId": "0x534e5f5345504f4c4941",
"revision": 1
}
// continuing from above Go code
domainHash, err := ttd.GetStructHash(ttd.Revision.Domain())
if err != nil {
panic(fmt.Errorf("fail to get struct hash: %w", err))
}
fmt.Println("domain hash:", domainHash)
// 0x6c09a2d2b4766fc27839cddedc0bf21408750397698671710b1ea8fd7761287
Cairo:
let domain = StarknetDomain {
name: metadata::name(),
version: metadata::version(),
chain_id: get_tx_info().unbox().chain_id,
revision: 1
};
println!("domain: {:?}", domain);
println!("domain_struct_hash: {:?}", domain.hash_struct());
// STDOUT: domain: StarknetDomain { name: 6611955555956948379282337524076, version: 49, chain_id: 393402133025997798000961, revision: 1 }
// STDOUT: domain_struct_hash: 642745170559712649122281357821340941879369528611034460916757525855769477160
// Convert decimal to hex
// domain_struct_hash: 0x16BC7E7664F47B684E3FC1CD8EE48CAFFEEC098FC85CE1A024E820AA8A04028
// It does not match `0x6c09a2d2b4766fc27839cddedc0bf21408750397698671710b1ea8fd7761287`
After adding v
prefix,
"domain": {
"name": "StarkNet Mail",
- "version": "1",
+ "version": "v1",
"chainId": "0x534e5f5345504f4c4941",
"revision": 1
}
Re-running Go,
✦ ❯ go run main.go
domain hash: 0x4d66974991a172368812272d15c74c7894b3cedc08f36c576d837a47515e425
Re-running Cairo,
STDOUT: domain: StarknetDomain { name: 6611955555956948379282337524076, version: 30257, chain_id: 393402133025997798000961, revision: 1 }
STDOUT: domain_struct_hash: 2188084493039379785443876450040401224928080773759018043323204526752587441189
# 0x4D66974991A172368812272D15C74C7894B3CEDC08F36C576D837A47515E425
The domain hash matches! Isn’t that interesting?
Looking at OpenZeppelin cairo source code,
// From https://github.com/OpenZeppelin/cairo-contracts/blob/v0.17.0/packages/utils/src/cryptography/snip12.cairo#L35-L40
impl StructHashStarknetDomainImpl of StructHash<StarknetDomain> {
fn hash_struct(self: @StarknetDomain) -> felt252 {
let hash_state = PoseidonTrait::new();
hash_state.update_with(STARKNET_DOMAIN_TYPE_HASH).update_with(*self).finalize()
}
}
It’s not obvious why prefix v
matters when calculating domain hash in Cairo.
To conclude, if you ever find yourself implementing SNIP-12. I hope you find this useful.