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:
- Re-using the same mechanism that the one of the merkletree, introducing a new type
enum
. - 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 usearguments
instead oftype
. Aims to simplify the parsing of the types - Pre-pending the type by
enum::
A new field is introduced to the structtype
that should tell what is the type of the current object. We also update the object to usearguments
instead oftype
. Aims to simplify the parsing of the types - 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
- Signing transactions and off-chain messages · argentlabs/argent-x · Discussion #14 · GitHub
- Signature | Starknet.js
- EIP-712: Typed structured data hashing and signing
- https://github.com/0xs34n/starknet.js/blob/develop/__mocks__/typedDataExample.json
- https://github.com/0xs34n/starknet.js/blob/develop/src/utils/typedData.ts