Cairo v2.3.0 - release candidate

Cairo v2.3.0

TL;DR

Cairo v2.3.0-rc0 was just released. Since there were only high-level upgrades (i.e., no Sierra changes), it is associated with Sierra v1.3.0 (same as Cairo v2.2.0) which is compatible with Starknet ≥0.12.1 (that is, it is supported on all Starknet environments).

This version focuses on the introduction of components, continuing in the direction set with Cairo v2.0.0. Components are a way to achieve extensibility - enriching my contract’s functionality (storage, events, and external functions) with a module that a third party wrote. Since components deserve their own treatment, we will only link to the dedicated post here, and continue with other notable mentions in this version.

We encourage all devs to try out components while we’re in the release candidate phase and give early feedback, either here / github / or the Cairo discord.

Anonymous generic impl parameters

Generic impl parameters are useful when we want to write an implementation or functions that depends on some implementation of a given trait (somewhat analogous to rust’s trait bounds). So far, such generic impl params had to be named. Consider the following example:

impl HashImpl<
    T, 
    S, 
    impl IntoFeltT: Into<T, felt252>, 
    impl HashStateS: HashStateTrait<S>, 
    impl DropS: Drop<S>
> of Hash<T, S> {
    ...
}

The above is a declaration of an implementation of the Hash<T,S> trait from the core library. This implementations needs the ability to convert T to felt252, treat the generic S as a hash state which has some functionalities, and the ability to drop S. Although we never used the impl names in the body (IntoFeltT, HashStateS, DropS), we had to specify them. Now we can simply use the new + syntax and write:

impl HashImpl<
    T, 
    S, 
		+Into<T, felt252>, 
		+HashStateTrait<S>, 
		+Drop<S>
> of Hash<T, S> {
    ...
}

Impl alias

We can now give aliases to existing impls as follows:

impl MyName = integer::U16IntoFelt252;

This is particularly useful when instantiating generic impls with concrete types. Taking the above HashImpl as an example, we can instantiate some of the generic parameters to obtain impls that hash specific types (assuming they are convertible to felt252):

impl HashU8<S, +HashStateTrait<S>, +Drop<S>> = HashImpl<u8, S>;
impl HashU16<S, +HashStateTrait<S>, +Drop<S>> = HashImpl<u16, S>;
impl HashU32<S, +HashStateTrait<S>, +Drop<S>> = HashImpl<u32, S>;
impl HashU64<S, +HashStateTrait<S>, +Drop<S>> = HashImpl<u64, S>;
impl HashU128<S, +HashStateTrait<S>, +Drop<S>> = HashImpl<u128, S>;

Function-level annotation inside an impl

Up until now, there was no way to annotate specific functions inside an impl. This meant that constructor or l1_handler functions had to lie outside an impl, and annotated individually. While this is still supported, we introduce a way to add functions inside an impl to the ABI, in a way that allows having different types of entrypoints:

#[abi(per_item)]
#[generate_trait]
impl SomeImpl of SomeTrait {
    #[constructor]
    fn constructor(ref self: ContractState) {
			...
    }

    #[external(v0)]
    fn some_external_function(
        ref self: ContractState, arg1: felt252, arg2: felt252
    ) -> felt252 {
			...
    }

    #[l1_handler]
    fn handle_message(ref self: ContractState, from_address: felt252, arg: felt252) -> felt252 {
        ...
    }

	// this is an internal function
	fn internal_function(self: @ContractState) {
			...
	}
}

Using the #[abi(per_item)] annotation over an impl, we can annotate each function individually and decide whether it is a constructor, external function, or an L1 handler. Note that unlike the starknet interface traits, each function will appear in the ABI individually (rather than appearing together under the interface name). The impl and trait name will not be part of the ABI.

We encourage devs to stop using the #[external(v0)] annotation, which may be deprecated in the future. Instead, use #[abi(per_item)] or #[abi(embed_v0)] that is described below.

Embedding impls

The notion of embedding applies to impls of Starknet interfaces. That is, traits that are annotated with the #[starknet::interface] attribute (which means all the functions in the trait are expected to be called externally). Embedding such an impl means that all the functions inside it become entry points of the contract, and the impl and corresponding trait are added to the ABI.

Consider the following example:

#[starknet::interface]
trait ICounterContract<TContractState> {
    fn increase_counter(ref self: TContractState, amount: u128);
    fn decrease_counter(ref self: TContractState, amount: u128);
    fn get_counter(self: @TContractState) -> u128;
}

#[abi(embed_v0)]
impl CounterContract of ICounterContract<ContractState> {
    fn get_counter(self: @ContractState) -> u128 {
			...
    }

    fn increase_counter(ref self: ContractState, amount: u128) {
			...
    }

    fn decrease_counter(ref self: ContractState, amount: u128) {
			...
    }
}

The differences from embed(per_item) are:

  • since this is a starknet interface trait (traits for which we create dispatchers and expect their functions to be called externally), there is no function-level annotation, all the functions end up as external
  • rather than appearing in the ABI individually, we will find the following interface and impl entries in the ABI:
{
      "type": "impl",
      "name": "CounterContract",
      "interface_name": "<namespace>::ICounterContract"
},
{
    "type": "interface",
    "name": "<namespace>::ICounterContract",
    "items": [
        {
            "type": "function",
            "name": "increase_counter",
            "inputs": [
							...
            ],
            "outputs": [],
            "state_mutability": "external"
        },
        {
            "type": "function",
            "name": "decrease_counter",
            "inputs": [
							...
            ],
            "outputs": [],
            "state_mutability": "external"
        },
        {
            "type": "function",
            "name": "get_counter",
            "inputs": [],
            "outputs": [
							...
            ],
            "state_mutability": "view"
        }
    ]
}

Note that in this role, #abi(embed_v0) behaves exactly the same as #[external(v0)]. We recommend transitioning to using #[abi(embed_v0)], as it is more aligned with the new set of attributes presented here.

Embeddable impls

The #[abi(embed_v0)]attribute appears in another context, and thus generalizes the old #[external(v0)] attribute. We can use #[abi(embed_v0)]over aliases to impls that are embeddable, i.e. annotated with #[starknet::embeddable] or #[embeddable_as(<name>)].

The use of embeddable impls is mostly relevant to components (see the component’s post to understand how embeddable_as is used within a component).

Relaxing the Event enum limitations

We no longer enforce the types of the Event enum variants to be structs of the same name. They can now be themselves enums, and of arbitrary names, as illustrated below:

#[starknet::contract]
mod my_contract {
	...

	#[event]
	#[derive(Drop, starknet::Event)]
	enum Event {
	    MyCounterIncreased: CounterIncreased,
	    MyCounterDecreased: CounterDecreased,
	    UpgradableEvent: upgradable_component::Event
	}
}

#[starknet::component]
mod upgradable_component {

	...
	
	#[event]
	#[derive(Drop, starknet::Event)]
	enum Event {
	    ContractUpgraded: ContractUpgraded
	}
	
	#[derive(Drop, starknet::Event)]
	struct ContractUpgraded {
	    old_class_hash: ClassHash,
	    new_class_hash: ClassHash
	}
}

Whenever an enum that derives the starknet::Event trait has an enum variant, it is nested by default. That is, we get another selector to the list of keys corresponding to the variant’s name. For example, emitting the following event:

self.emit(upgradable_component::ContractUpgraded(ContractUpgraded { old_class_hash: ..., new_class_hash: ...}))

Will emit an event with two keys: sn_keccak(UpgradableEvent), sn_keccak(ContractUpgradade), and two data elements (old and new class hashes). Note that we won’t actually be writing the above long emit line, as this describes an event emitted by a component (it’s not the role of the using contract to emit it).

We can opt out of this nested behavior by specifying that the enum variant is flat:

#[event]
#[derive(Drop, starknet::Event)]
enum Event {
    MyCounterIncreased: CounterIncreased,
    MyCounterDecreased: CounterDecreased,
		#[flat]
    UpgradableEvent: upgradable_component::Event
}

In which case we only get one key and two data elements (the variant name UpgradableEvent is ignored in serialization). The flattening feature is intended to allow backward compatibility by having components emitting the same events as those that would have been emitted if we were to write the same logic in standalone contracts. Nested events have the benefit of allowing us to distinguish between different component events (or collision between variant names in the contract and the component), a property that is lost when we don’t allow nesting.