[SNIP] Off-chain signatures (à la EIP712)

Abstract

Just as in EIP712, this is a standard for hashing and signing typed structured data as opposed to just hexadecimal (or felt) values in Starknet.

The purpose is NOT to define how you should design your protocol.

Motivation

Signing blindly some random hexadecimal is not very user-friendly, but on top of that, it is very dangerous. It is important for the user to understand what he is about to sign by showing him values he can understand.

This document aims to create a standard that’s compatible with existing Dapps, wallets, and smart contracts while also adding some extra functionality to express the new types introduced in Cairo. This document consolidates some previous efforts to create off-chain signatures in Starknet (some of which were not well documented).

Specification

Inspired by EIP-712, we can define the encoding of an off-chain message as:

signed_data = encode(PREFIX_MESSAGE, Enc[domain_separator], account, Enc[message])

h(array)
This depends on the hashing_function used (cfr. Domain separator), please refer to the documentation:


`starknet_keccak(str)` as the starknet_keccak hash on str https://docs.starknet.io/documentation/architecture_and_concepts/Cryptography/hash-functions/#starknet_keccak
`serialise(x)` as the way cairo transforms the value into a felt
## Prefix message

The PREFIX_MESSAGE must be StarkNet Message.
This is intended to distinguish between a message sent off-chain for future use and a transaction that will be directly sent to the sequencer for on-chain processing.

Domain separator

The domain_separator is defined as the struct below.

For compatibility reasons, the fields can be both felts and strings. But both Dapp and Contract must use the same types.

struct StarkNetDomain {
  name: felt || string,
  version: felt || string, 
  chain_id: felt || string,
  hashing_function: 'pedersen' || 'poseidon',
}

This struct ensures the uniqueness of messages based on:

  • name: The name of the Dapp, can even contain the function name if your contract needs to perform multiple hashes.
  • version: The version of the Dapp your contract is using. Prevents two versions of the same Dapp from producing the same hash. Typically, if you update your contract and the hashing behaviour changes, this field should be updated.
  • chaind_id: The chain ID used by the Dapp represented as a felt. Prevents replay attacks from one network to another.
  • hashing_function: Should be ‘pedersen’ or ‘poseidon’. This field can be omitted, it’ll then assume pedersen is used.

Account

The account is the contract address of the Account Contract that is signing.
This prevents two accounts from producing the same hash.

Message

Is the transaction message to be signed represented as a struct.

How to work with each type

type_hash(x) = starknet_keccak(encode_type(x))

Note that the type_hash is constant for a given struct/enum and does not need to be runtime computed.

encode_type will be the “type” defined in this document, unless there’s a different specification for the “encoded_type”

When X is a structure

encoding

Enc[x] = h(type_hash(MyStruct), Enc[param1], Enc[param2], ..., Enc[paramN])

Example:

struct MyStruct {
	param1: felt,
	param2: felt*,
	param3: string,
	param3: selector,
	param4: OtherStruct,
	param5: merkletree,
	...
	paramN:felt
}

encode_type

name || "(" || param1 || ":" || param1_type || "," || ... || paramN || ":"|| paramN_type || ")"

If the structure references other structs/enum which can also reference other structs/enum, the set of referenced structs/enum is collected, sorted by name, and appended to the encoding.

If we take back our example used previously, we have:

type_hash(MyStruct) = starknet_keccak('MyStruct(param1:felt,param2:felt*,param3:string,param4:OtherStruct,param5:merkletree,...,paramN:felt)OtherStruct(param1:felt...)')

When X is a basic type

encoding

Enc[x] = serialise(x)

encode_type

X can be felt for backward compatibility.
But it can also be felt252, u8, u16,… u128, i8, i16,… i128, ContractAddress , ClassHash

The list is not complete as more types are being added to cairo. The rule is to include every field defined in the corelib as extern type Name; .
Note that types like u256 are struct so they don’t belong here.
Types that are defined elsewhere in this specification also don’t belong here.

When X is a bool

encoding

Enc[x] = serialise(x)

0 for false
1 for true

encode_type

bool

When x is a string

Encoding

Enc[x] = serialise(x)

The string represented as a felt.
String only allow a maximum of 31 ASCII characters.

encode_type

string

When X is a selector

Encoding

Enc[x] = starknet_keccak(x)

This represents the name of a smart contract function.

encode_type

selector

When X is an enum

Encoding

Enc[enum] = h(type_hash(enum), variant_index, Enc[chosen_variant_parameter1],..., Enc[chosen_variant_parameterN])

Example:

enum MyEnum {
	Variant1,
	Variant2: (u32, felt*),
	...
	VariantN:(felt)
}

encode_type

enum_name || "(" || Variant1 || "(" || param1_type || "," || ... || paramN_type || ")," || ... || VariantN || "(" || ... || ")" || ")"

If the enum references other struct/enum which can also reference other structs/enum, the set of referenced struct/enum is collected, sorted by name, and appended to the encoding.

If we take back our example used previously, we have:

type_hash(MyEnum) = starknet_keccak('MyEnum(Variant1(),Variant2(u32,felt*),...,VariantN(felt))')

...
// Option 1: 
Example: [
  { name: "some_enum", type: "enum", contains: "MyEnum" },
],
"MyEnum": [
	{ name: "Variant1", type: "()" }
	{ name: "Variant2", type: "(u32, felt*)," }
	...
	{ name: "VariantN", type: "(felt)" }
],

// Option 2: 
Example: [
  { name: "some_enum", type: "enum::MyEnum" },
],
"enum::MyEnum": [
	{ name: "Variant1", arguments: "()" }
	{ name: "Variant2", arguments: "(u32, felt*)," }
	...
	{ name: "VariantN", arguments: "(felt)" }
],

// Option 3: 
Example: [
  { name: "some_enum", type: "enum::MyEnum" },
],
MyEnum: {
  type: "enum",
	variants : [	
		{ name: "Variant1", arguments: "()" }
		{ name: "Variant2", arguments "(u32, felt*)," }
		...
		{ name: "VariantN", arguments: "(felt)" }
  {
]

// Option 4: 
Example: [
  { name: "some_enum", type: "MyEnum(Variant1,Variant2,Variant3)" },
],
"MyEnum::Variant1" : {},
"MyEnum::Variant2" : {
  { name: "some_number", type: "u32" },
  { name: "some_array", type: "felt*" },
},
"MyEnum::Variant3" : {
  { name: "some_felt", type: "felt" },
},

...
...
some_enum: { Variant2:[32, [12, 32]] }
...

We need your help

We thought about 3 options on how to deal with the new enum type:

  1. Re-using the same mechanism that the one of the merkletree, introducing a new type enum.
  2. Pre-pending the type by enum::
    The name should then also be present in the one it refers to. We also update the object to use arguments instead of type . Aims to simplify the parsing of the types
  3. Pre-pending the type by enum::
    A new field is introduced to the struct type that should tell what is the type of the current object. We also update the object to use arguments instead of type . Aims to simplify the parsing of the types
  4. Ensuring that all the arguments are named would improve readability as the user will always be able to tell the meaning of each argument. In this scenario the arguments can be represented as a struct (meaning we don’t have special types for enums).

With option 2 and 3 there is also the question about structs. If we introduce this enum:: syntax, should we also do the same for structs: Struct::myStruct?. To stay retro-compatible this wouldn’t be required, but it would aim to better readability adding some complexity

Feel free to come up with another option (or a mix of what we proposed), we are really looking for the best option that would convince the most people.

When X is a merkletree

encoding

Enc[X=(x0, x1, ..., xN)] = calculate_merkle_tree_root(x0, x1, ..., xN)

X is a list of items of the same type that we will sign as a merkle tree.

Using the hashing function given in the Starknet domain object

encode_type

merkletree

On the wallet level, providing just the merkletree root without including any data isn’t safe. The wallet also needs to receive the data, which is why an additional parameter is required.
The parameter contains needs to be specified, it will refer to a struct that will be used to represent the leaves as a struct:

...
Example: [
  { name: "contract_addresses", type: "merkletree", contains: "Leaf" },
],
Leaf: [
	{ name: "contract_address", type: "ContractAddress" }
]
...

The Merkle tree will use the hashing function given in the Starknet domain. The wallet will receive a list of leaves from the Dapp, so the leaves can be shown to the user. It should then perform the hashing on all the leaves and ensure that the root is the same:

...
contract_addresses: [
	{
		contract_address:"0x...123"
	},
	...,
	{
		contract_address:"0x..beaf"
	}
]
...

In order to calculate the Merkle root the wallet will encode each leave to a single felt (using the same encoding used in this document).

When verifying the off-chain signature, only the root of the tree needs to be provided to the contract. Verifying a Merkle proof will require the verification of the off-chain signature plus the verification of the proof.

When X is an array

encoding

Enc[X=(x0, x1, ..., xN)] = h([Enc[x0], Enc[x1], ... Enc[xN]])

encode_type

An array of type InnerType has to be encoded as InnerType*.
The inner type could be any of the other types supported in this specification.

JSON example

{
  "types": {
    "StarkNetDomain": [
      { "name": "name", "type": "string" },
      { "name": "version", "type": "string" },
      { "name": "chainId", "type": "string" }
    ],
    "ExampleStructMessage": [
      { "name": "name", "type": "string" },
      { "name": "some_array", "type": "u128*" },
      { "name": "some_struct", "type": "MyStruct" }
    ],
    "MyStruct": [
      { "name": "some_selector", "type": "selector" },
      { "name": "some_contract_address", "type": "ContractAddress" },
    ],
  },
  "primaryType": "ExampleStructMessage",
  "domain": {
    "name": "Starknet Example",
    "version": "1",
    "chainId": "SN_MAIN"
  },
  "ExampleStructMessage": {
    "name": "some_name"
    "some_array": [1, 2, 3, 4],
    "some_struct": {
      "some_selector": "transfer",
      "some_contract_address": "0x0123"
    },
  }
}

Reference implementation

Find here an example repository for more detailed examples.

Note that this implementation uses Pedersen as the hashing function.

References

  1. Signing transactions and off-chain messages · argentlabs/argent-x · Discussion #14 · GitHub
  2. Signature | Starknet.js
  3. EIP-712: Typed structured data hashing and signing
  4. https://github.com/0xs34n/starknet.js/blob/develop/__mocks__/typedDataExample.json
  5. https://github.com/0xs34n/starknet.js/blob/develop/src/utils/typedData.ts

I’ve heard poseidon hashes should be preferred. Are there backwards compatibility concerns? Perhaps to support both a "hash": "pedersen" | "poseidon" field could be added to the domain.

Hello,
I think we should also standardize how a browser wallet should display the message when waiting the account owner approval. Today, it’s like this :

The EIP712 provides recommendations how to display the message to improve the user experience :

I think that we should also explain in this SNIP how each message field should be displayed. For example, the StarkNetDomain fields should be all displayed with their titles, at the top of the window.
The standardization and the quality of the display is also important to reach the goal of EIP712.

Yes, the intention of the SNIP is to formalize what the ecosystem is doing today but still introduce improvements if they are backward compatible.

I like the idea of anticipating the transition to poseidon. Adding an optional hash parameter to the domain makes sense provided that we define the default to be pedersen when the parameter is not specified.

Hey @frangio
I just updated the description to mention the use of the hashing function.
Could you have a look to make sure it suits what you were asking? :slight_smile: