Starknet Standard Interface Detection

Having the ability to check if a smart contract implements a specific interface is a very important feature that supports interoperability patterns (ERC721 transferFrom, etc…). In the Ethereum ecosystem, the ERC165 specifies the “how to”, and is widely used as one of the oldest standards. In Starknet, some implementations have somehow used this Ethereum Standard for defining interface ids (like Account implementors), for being able to implement ERCs that depend on it, but there’s a lack of a formal definition, and Cairo types have been mapped to Solidity types on the fly. In the beginning, with Cairo 0 this formalization was unpractical because the language itself was likely to change a lot, but now we have achieved certain maturity with Cairo 1, which I think allows us to define in the right way this process of interface ids generation.

I propose defining an SNIP from the ERC-165 idea, but using Cairo types instead. While in the past we’ve used these Solidity types for the interface generation (Account example):

interface IAccount {

    struct Call {
        address to;
        bytes4 selector;
        bytes data;
    }

    function supports_interface(bytes4) external;
    function is_valid_signature(bytes32, bytes32[] calldata) external;
    function __execute__(Call[] calldata) external;
    function __validate__(Call[] calldata) external;
    function __validate_declare__(bytes32) external;
}

One of the problems with this is that for example, we would obtain the same interface for a param being Span<T> or Array<T>, and we would need to define if we use bytes32 or uint256 from Solidity when we have the u256 type in Cairo.

I propose having something like this:

struct Call {
    to: ContractAddress,
    selector: u32,
    calldata: Array<felt252>
}

trait IAccount {
    fn supports_interface(u32);
    fn is_valid_signature(felt252, Array<felt252>);
    fn __execute__(Array<Call>);
    fn __validate__(Array<Call>);
    fn __validate_declare__(felt252);
}

Translated into this signatures:

functionSignatures = [
    'supports_interface(u32)',
    'is_valid_signature(felt252,Array<felt252>)',
    '__execute__(Array<(ContractAddress,u32,Array<felt252>)>)',
    '__validate__(Array<(ContractAddress,u32,Array<felt252>)>)',
    '__validate_declare__(felt252)'
]

Being the interface id the XOR of them (similar to Solidity), that can be computed with this Python script as an example:

# pip install pysha3
import sha3

keccak_256 = sha3.keccak_256
functions = [
    'supports_interface(u32)',
    'is_valid_signature(felt252,Array<felt252>)',
    '__execute__(Array<(ContractAddress,u32,Array<felt252>)>)',
    '__validate__(Array<(ContractAddress,u32,Array<felt252>)>)',
    '__validate_declare__(felt252)'
]

def get_id(fn_signature):
    id = keccak_256(fn_signature.encode()).hexdigest()[0:8]
    print(fn_signature, id)
    return id

def main():
    interface_id = 0x0
    for fn in functions:
        interface_id ^= int(get_id(fn), 16)
    print(hex(interface_id))

if __name__ == '__main__':
    main()

With this SNIP we would have a settled definition of how to compute interface ids for Starknet, and this will support interoperability as ERC-165 does for Ethereum.

Hey,
Regarding The Great Interface Migration
For some time account interface should both support
supportsInterface(felt) and supports_interface(u32)
Do you think that the old supportsInterface(felt) should be true only for the old interface IDs and the new one should only support the new ones that you are describing here?
Or they should both support all of them for the time being?

Hi Eric,

First of all, i think this is a great initiative. IMO it made no sense to try to be that close to ethereum.

If we are changing they way we calculate the interface id, would it also make sense to make interface if a full felt252? I don’t see the need to follow ethereum bytes4 size. I creates many collisions, some of them even accidentally. And we also use felts for methods selectors on Starknet. Also. looks like it’s compatible with the old interface in Cairo 0 which expected a felt.

On another topic. The example for the Account interface looks great, but how would it look like if we have customs Structs? I think we need to agree on a way to do those too.

First of all, i think this is a great initiative. IMO it made no sense to try to be that close to ethereum.

If we are changing they way we calculate the interface id, would it also make sense to make interface if a full felt252? I don’t see the need to follow ethereum bytes4 size. I creates many collisions, some of them even accidentally. And we also use felts for methods selectors on Starknet. Also. looks like it’s compatible with the old interface in Cairo 0 which expected a felt.

On another topic. The example for the Account interface looks great, but how would it look like if we have customs Structs? I think we need to agree on a way to do those too.

Starting with the second topic, we have Call in the example being a custom struct, my idea is to follow what Solidity does with structs, decomposing them in tuples recursively. You can see the example here:

__execute__(Array<(ContractAddress,u32,Array<felt252>)>

About the first topic, we could maintain u32 instead of felt252 just to keep “easier to handle” ids. Is easier to check 0xc5d24601 than this 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 right?

Maybe is more gas costly to handle u32 than felt252 though.

Do you think that the old supportsInterface(felt) should be true only for the old interface IDs and the new one should only support the new ones that you are describing here?
Or they should both support all of them for the time being?

I think both methods should behave the same, returning true for every interface the contract supports. Keep in mind that what I’m proposing is a way of standardizing how interface ids are created, but it doesn’t enforce camelCase or snake_case, so you can still have merged syntax and generate a valid interface id using this mechanism. The use of snake_case is just a convention with important implications for interoperability.

With Accounts, for example, we would maintain camelCase and snake_case, generate two ids using this standard for both interfaces, and support both numbers in both methods.

All good regarding structs then, that should work. What about Spans vs Arrays? Any idea of what to do with them? Should we consider them different?

Regarding the selectors. I see your point, you can quickly check it visually. But you can also look only at the beginning or end to achieve the same. I would still have a full felt252 because:

  • It’s consistent with starknet selectors
  • It’s safer, avoids collisions
  • Should more performant as you don’t need to check that the first bytes are all zeros. Although it should be minimal

Just my opinion :slight_smile:

Thanks @ericnordelo for the initiative.

I agree with going with felt252 to save gas. Are there any substantial benefits for using u32 that I missed?

What about Spans vs Arrays? Any idea of what to do with them?

Span and Array are different types (even when they represent similar things). The compiler will even start treating Span as an owned type soon, as you can see from this PR. I think we should handle them as different types accordingly.

Are there any substantial benefits for using u32 that I missed?

Besides shorter (aka “easier to handle”) ids I don’t think so. I agree it could be good to maintain the consistency with selectors and save some gas using felt252. Note that function selectors can’t be used for id generation though, as they do not include parameters in Starknet, and the interface id should represent input types as well.

I’d like to provide an updated example of how it will look like if we stick with felt252 for selectors

This would be interface in Cairo:

struct Call {
    to: ContractAddress,
    selector: felt252,
    calldata: Array<felt252>
}

trait IAccount {
    fn supports_interface(felt252);
    fn is_valid_signature(felt252, Array<felt252>);
    fn __execute__(Array<Call>);
    fn __validate__(Array<Call>);
    fn __validate_declare__(felt252);
}

And this is the python code that computes the interface id. Note that i updated the hash function to starknet_keccak instead of keccak_256

# pip install cairo-lang
from starkware.starknet.public.abi import starknet_keccak

functions = [
    'supports_interface(felt252)',
    'is_valid_signature(felt252,Array<felt252>)',
    '__execute__(Array<(ContractAddress,felt252,Array<felt252>)>)',
    '__validate__(Array<(ContractAddress,felt252,Array<felt252>)>)',
    '__validate_declare__(felt252)'
]


def main():
    interface_id = 0x0
    for function in functions:
        function_id = starknet_keccak(function.encode())
        print(f"{hex(function_id)} hash for {function}")
        interface_id ^= function_id
    print('IAccount ID:')
    print(hex(interface_id))


if __name__ == "__main__":
    main()

Which outputs the following ID for IAccount

0x396002e72b10861a183bd73bd37e3a27a36b685f488f45c2d3e664d0009e51c

Do you think it’s ok? Any feedback is appreciated

Thanks @sgc-code , that looks good. I will work on a proper SNIP proposal based on this to continue the discussion.

Here is the SNIP Draft’s first version:

cc @sgc-code @yoga_Braavos

What do you mean by consistent? what does interface id type definition have to do with selectors?

In ERC-165 they define the interface id as:

the XOR of all function selectors in the interface

Since selectors are bytes4 in EVM world, that makes the interface id a bytes4 too.

Following the same logic, i think, the interface id should be a felt252 in Starknet.

Here is an update on the elements we’ve been discussing in other channels (the Draft has been updated accordingly):

  • Generic types have been removed from the standard because they can’t be part of the external API of a contract.
  • Arguments of external functions must use a default serializer, for the contract to be SNIP-5 compliant. This supports easier interoperability, allowing calling contracts to trust that the behavior of two different targets exposing the same interface would be compatible regarding serialization. The standard for Tuples, Structs, and Enums is the same that we get from using the derive attribute which is: the concatenation of the serialized fields for Structs and Tuples, and the concatenation of a felt252 acting as a variant identifier and the serialized value for Enums.
  • Types defined in corelib as external types will be treated as core types, but structs and Enums from corelib will be treated as such (ex: u256 is represented as (u128,u128)).
  • The interface id is the starknet_keccak of the XOR of the Extended Function Selectors with the given signature (ASCII encoded).

Also, in ERC-165, a part of the specification is that the supports_interface method must return false for the interface 0xffffffff. As far as I understand, this was included for backward compatibility, to determine that contracts previous to this standard don’t implement the interface. This is not included in the SNIP because it seems unnecessary for the current Starknet state.

With the latest changes about the syntax evolving
Do you think it is useful to add self in the computation?

In my opinion, yes. From the fact that self type is not always the same (@ContractState or ref ContractState), I think it should be part of the signature as any other parameter. Having represented in the signature whether the function can modify the storage or not, looks like a nice extra feature to me. Interested in others’ thoughts.

Shahar also mentioned that the Account interface could be updated to something that looks like:

trait Account<State, TxInp, TxOut> {
  fn __validate__(ref self: State, inp: TxInp) -> bool;
  fn __execute__(ref self: State, inp: TxInp) -> TxOut;
}

I guess this would also have an impact and needs to be agreed on.

This is interesting, I actually removed the Generics section of the SNIP-5 after some discussion, because generic types weren’t allowed in external functions for smart contracts. I think this behavior remains, so this generic Account trait would not actually be representing the interface, but a blueprint of interfaces that will be defined after implementing the trait with specific types.

Not sure if we want to allow an interface (for SNIP-5 interoperability) with these generic types.