Cairo 1: Contract Syntax is Evolving

Cairo 1: Contract Syntax is Evolving

TL;DR

  • We’re introducing changes to the syntax of Cairo 1 to improve safety and extensibility.

  • Transitioning from existing Cairo 1 code to the new syntax should be fairly easy and straightforward; only the “outermost layer” of the contract is affected.

  • The new syntax will take effect in Cairo’s next compiler version (v2.0.0), that will become available on Starknet v0.12.0.

  • The existing and new Cairo 1 syntaxes will live side by side for at least six months. That is, we encourage developers to migrate over to the new Cairo 1 compiler syntax, and you have six months to do so. This guarantee will hold for any future breaking changes on the compiler; contracts compiled with an old compiler version will be accepted on Starknet for at least six months after deprecation.

  • The new syntax will come in two phases. Only the first phase includes breaking changes.

    • The first phase focuses on safety (e.g., enforcement on view functions, clear distinction between pure functions and functions that modify the state)

    • The second phase focuses on extensibility - allowing smart contracts to use components written in external libraries.

  • We’re working with Cairo 1 auditors to ensure that re-auditing of Cairo 1 code will be painless and inexpensive.

  • This is a technical post intended for developers and anyone who likes reading about smart contract languages; proceed at your own peril!

Introduction

In this post, we present the new proposed syntax for contracts written in Cairo 1.0. The Cairo 1.0 compiler was first introduced in January 2023 and is continually being improved and enriched with new features. While being a standalone language for writing provable programs, the Starknet plugin of the compiler is responsible for the smart contract aspect of the language. Via a system of dedicated attributes, the Starknet contracts syntax was built on top of Cairo 1.0.

When we initially released Cairo 1.0, our motivation was to make it available for smart contract developers as soon as possible. With this in mind, the contract syntax mostly resembles that of Cairo 0. While good for the initial migration, this does not follow the trend of making Cairo 1.0 more Rusty, and does not achieve many safety properties that are now possible with the new language. This was also the community’s sentiment toward the existing syntax. Community members felt that we should use the opportunity of transitioning to Cairo 1 to also improve on the smart contracts syntax and address some of the known pain points (e.g., how events are currently handled). The changes we’re presenting here were shaped by valuable community feedback over the last few weeks.

Our motivation behind this change is achieving this “safe” (in a precise sense we will elaborate on) contract syntax, in a way that builds on the Cairo 1.0 language and does not require dedicated elements for the smart contract language. In the following sections, we will unveil the new proposed syntax, dive into the new components, and explain what desirable properties they bring to our smart contracts language.

What we have today

We start by examining a simple contract that maintains a counter and allows users to increase or decrease it, where decreasing is pending approval from an external contract.

#[abi]
trait IOtherContract {
    fn decrease_allowed() -> bool;
}

#[contract]
mod CounterContract {
    use starknet::ContractAddress;
    use super::{
        IOtherContractDispatcher, 
        IOtherContractDispatcherTrait, 
        IOtherContractLibraryDispatcher
    };

    struct Storage {
        counter: u128,
        other_contract: IOtherContractDispatcher
    }

    #[event]
    fn counter_increased(amount: u128) {}
    #[event]
    fn counter_decreased(amount: u128) {}

    #[constructor]
    fn constructor(initial_counter: u128, other_contract_addr: ContractAddress) {
        counter::write(initial_counter);
        other_contract::write(IOtherContractDispatcher { contract_address: other_contract_addr });
    }

    #[external]
    fn increase_counter(amount: u128) {
        let current = counter::read();
        counter::write(current + amount);
        counter_increased(amount);
    }

    #[external]
    fn decrease_counter(amount: u128) {
        let allowed = other_contract::read().decrease_allowed();
        if allowed {
           let current = counter::read();
           counter::write(current - amount);
           counter_decreased(amount);
        }
    }

   #[view]
   fn get_counter() -> u128 {
      counter::read()
   }
}

The compiler handles specific Starknet-related attributes via the Starknet plugin (think of it as an extension of the Cairo 1.0 compiler). Note the following key points in the existing contracts syntax:

  • The contract is defined inside its own module, marked by the #[contract] attribute.

  • The contract’s storage lies inside the Storage struct.

  • Events are marked with the #[event] attribute and are defined as functions, in the tradition of Cairo 0.

  • The contract’s functions are marked with the #[external] attribute and can be defined anywhere inside the contract module.

  • Interacting with other contracts can be done by defining a trait that contains the external contract’s functionality. Marking this trait with the #[abi] attribute causes the compiler to generate the dispatcher struct, which can be used for calling the contract.

Extendability and Safety - the New Contract Syntax

While natural and, in a way, consistent with the Cairo 0 syntax, there are a few concerns that are not addressed by the existing syntax. These concerns are the primary motivation driving the update to the contract syntax. We summarize them below:

Extendability: Allow contracts to use components written in external libraries. This is addressing a significant blocker raised by the community, hindering the management of large projects having many different interacting parts.

Safety: We want to make the contract’s behavior as explicit as possible. This improves clarity on what exactly the contract is doing and how we should think of each of its external functions. Getting a clear view of the contract helps avoiding mistakes, which in the world of blockchain means we may save a significant amount of money by sidestepping bugs. We split the related changes into three categories:

  • Contract Interface: By looking at the above contract, it is unclear what its external behavior is. We want to make the interface of the contract clear by leveraging traits & impls from Cairo 1.0. In fact, you will get a compilation error if your contract doesn’t conform with the declared interface. Thanks to this change, it’s easier to acertain that we didn’t miss any potential way to interact with the contract.

  • Storage: Similarly to interface safety, we want the contract’s state to be explicitly declared in the contract. This is already achieved with the Storage struct. However, one safety aspect that isn’t addressed by the current handling of storage variables is avoiding side effects. One has to examine the body of the external functions in order to know whether or not they mutate the state. We would like this to be explicit, making it clear in the function’s signature that the state is mutated. Another desirable consequence of storage safety is the ability to enforce the #[view] attribute in the compiler, clearly distinguishing between functions that mutate the state and those that only read from it.

  • Events: You’ve probably gotten the gist by now, but just like the contract’s external functions and storage, we want a clear structure to the contract’s events (as opposed to being able to define them anywhere in the contract). In addition, now that the language has become rich enough, we would like to move away from the function-like syntax for events and transition to a more natural and “Rusty” syntax.

Revealing the new syntax

With the above goals in mind, let us consider the new proposed syntax for the simple counter contract we wrote in the previous section:

#[starknet::interface]
trait IOtherContract<TContractState> {
    fn decrease_allowed(self: @TContractState) -> bool;
}

#[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;
}

#[starknet::contract]
mod CounterContract {
    use starknet::ContractAddress;
    use super::{
        IOtherContractDispatcher, 
        IOtherContractDispatcherTrait, 
        IOtherContractLibraryDispatcher
    };

   #[storage]
   struct Storage {
      counter: u128,
      other_contract: IOtherContractDispatcher
   }

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

    #[derive(Drop, starknet::Event)]
    struct CounterIncreased {
        amount: u128
    }

    #[derive(Drop, starknet::Event)]
    struct CounterDecreased {
        amount: u128
    }

   #[constructor]
   fn constructor(ref self: ContractState, initial_counter: u128, other_contract_addr: ContractAddress) {
      self.counter.write(initial_counter);
      self.other_contract.write(IOtherContractDispatcher { contract_address: other_contract_addr });
   }

   #[external(v0)]
   impl CounterContract of super::ICounterContract<ContractState> {
      fn get_counter(self: @ContractState) -> u128 {
         self.counter.read()
      }

      fn increase_counter(ref self: ContractState, amount: u128) {
         let current = self.counter.read();
         self.counter.write(current + amount);
         self.emit(Event::CounterIncreased(CounterIncreased { amount }));
      }

      fn decrease_counter(ref self: ContractState, amount: u128) {
         let allowed = self.other_contract.read().decrease_allowed();
         if allowed {
            let current = self.counter.read();
            self.counter.write(current - amount);
            self.emit(Event::CounterDecreased(CounterDecreased { amount }));
         }   
      }
   }
}

We now proceed to examine the differences and see how they comply with our goals.

Contract interface as a generic trait

Perhaps the most obvious change is the addition of the following trait in the contract module, marked with the #[starknet::interface] attribute.

   #[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;
   }

Note the generic type TContractState of the self argument which is passed by reference to the increase_counter and decrease_counter functions. The self parameter stands for the contract state. Seeing the self argument passed by reference to increase_counter tells us that it mutates the state, as it is what gives us access to the contract’s storage. This behavior is in line with Cairo 1.0 syntax; that is, contracts don’t present unique language behaviors. The ref modifier implies that self may be modified and is implicitly returned at the end of the function, returning ownership to the caller. For more details on the behavior of Cairo references, see the book. On the other hand, get_counter gets a snapshot of TContractState, which immediately tells us that it does not modify the state (and indeed, the compiler will complain if we try to modify storage inside get_counter).

In general, one can define a contract interface that depends on more generic arguments. For example, the following trait defines a similar counter contract, but with a generic counter type:

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

The ICounterContract trait, when instantiated with concrete types, completely defines the interface of a counter contract. This addresses both interface safety and storage safety. We no longer need the #[external] attribute for functions defined under the contract module, as our external functions will be in the implementation of the ICounterContract trait. Even more importantly, external functions no longer have side effects, it is clear from the signature whether our functions modify the state. Functions that only require read access to the state, which used to be marked with the #[view] attribute, simply receive a snapshot of TContractState instead of a reference.

An additional benefit of always explicitly writing the contract interface is that the compiler will generate a corresponding dispatcher, allowing interaction with this contract. That is, any external project needing to interact with this contract can simply import the dispatcher, rather than having to define the interface again.

Contract logic as an impl of the interface trait

As mentioned above, since the contract interface is defined in the ICounterContract trait, the external functions of the contract are defined in an implementation of this trait:

#[external(v0)]
impl CounterImpl of super::ICounterContract<ContractState> { 
   ... 
}

Here, the generic parameter corresponding to the self argument in the trait, must be ContractState.
The ContractState type is generated by the compiler, and gives access to the storage variables defined by the Storage struct. Additionally, ContractState gives us the ability to emit events. The name ContractState is not surprising, as it’s a representation of the contract’s state, which is what we think of self in the contract interface trait.

Note that this is a regular Cairo 1.0 impl definition. We haven’t introduced language components that are unique to Starknet smart contracts. That is, a good understanding of the language immediately lends itself to the smart contracts elements. One doesn’t have to consider the smart contract syntax as a separate language.

Read the next section to see why we need the seemingly useless v0 specification in the external attribute.

Multiple Impls

Recall that Cairo 1.0 introduced named implementations (with the motivation of allowing the import of modules with different impls for the same trait). Multiple impls allow us to define different behaviors for the contract. For example, if we use the more general trait that allows generic counter type, maybe we want our contract to implement both the ICounterContract<ContractState, u256> and ICounterContract<ContractState, u128> traits. This is very intuitive to write with the new syntax. It does, however, introduce a new problem. What do we do if two impls contain the same function (name or even entire signature), how can a caller distinguish between the two?

There are several approaches to this problem. For example, we can introduce the notion of impl offset, which must be specified alongside the contract address upon calling the contract. Multiple impls of the same interface, or different impls containing the same function name, is a feature that we want to have in our smart contract language. However, this will not be part of the new syntax initially. At first, we will only allow a single implementation per interface, and disallow impls with the same function name.

Once multiple impls are introduced, the function selectors may depend on the impl containing them in some way (e.g. via the impl name or some attribute). To make sure that current contracts are future compatible, we added the v0 argument to the external attribute. This ensures that selectors of the contract’s function will not change, even when compiling with a future compiler that allows multiple impls.

Events

While the previous changes require getting used to (viewing contracts as impls for given traits), the new events syntax is rather straightforward. All the different contract events are defined under the Event enum, which implements the starknet::Event trait.

The following trait is defined in the Starknet core library:

trait Event<T> {
  fn append_keys_and_data(self: T, ref keys: Array<felt252>, ref data: Array<felt252>);
  fn deserialize(ref keys: Span<felt252>, ref data: Span<felt252>) -> Option<T>;
}

The #[derive(starknet::Event)] attribute causes the compiler to generate an implementation for the above trait, instantiated with the Event type, which in our example is the following enum:

#[event]
#[derive(starknet::Event)]
enum Event {
   CounterIncreased: CounterIncreased,
   CounterDecreased: CounterDecreased
}

This should be interpreted as follows: our contract contains two events, CounterIncreased and CounterDecreased, which are of types CounterIncreased and CounterDecreased correspondingly. The auto implementation of the starknet::Event trait will implement the append_keys_and_data function for each variant of our Event enum. The generated implementation will append a single key based on the variant name (CounterIncreased or CounterDecreased), and then recursively call append_keys_and_data in the impl of the Event trait for the variant’s type . In our example, self.emit(Event::CounterIncreased(CounterIncreased { amount: 5})) will emit an event with one key, sn_keccak(“CounterIncreased”), and one data element, 5, based on the generated implementation of the Event trait for the CounterIncreased type.

Note that the compiler will enforce the following structure of events:

  • The Event type must be an enum
  • Each variant type has to be a struct, and of the same name as the variant
  • Each variant type needs to implement the starknet::Event trait
  • The members in the type of each variant must implement Serde (the serialization will determine the keys/data elements added to the event)

In the previous example, we’ve seen events with a single key corresponding to the variant’s name, and one data element. What if we wanted to customize the serialization of the variant type, e.g. control which members are keys (that should be indexed by full nodes) and which are data members (expected to be stored but not indexed)? We can control this via the #[key] attribute over the members of the variant’s type.

Let us demonstrate this with a more real-world example:

#[derive(starknet::Event)]
struct Transfer {
   #[key]
   from: ContractAddress,
   #[key]
   to: ContractAddress,
   amount: u256
}

#[derive(starknet::Event)]
struct Approve {
   #[key]
   owner: ContractAddress,
   #[key]
   spender: ContractAddress,
   amount: u256
}

#[event]
#[derive(starknet::Event)]
enum Event {
   Transfer: Transfer,
   Approve: Approve
}

Suppose one of the external functions emits a Transfer event as follows:

use starknet::Felt252TryIntoContractAddress;
use traits::TryInto;

...

self.emit(Event::Transfer(Transfer { 
   from: 111.try_into().unwrap(),
   to: 222.try_into().unwrap(),
   amount: 333_u256
}));

How do we know what keys and values are now emitted? First, the generated implementation of the starknet::Event trait for the Transfer variant kicks in. At this stage, a key corresponding to the name Transfer, specifically sn_keccak(“Transfer”), is appended to the keys list. Next, the starknet::Event implementation for the Transfer struct kicks in. Both from and to are serialized as keys, thanks to the #[key] attribute, while amount is serialized as data. After all is said and done, we end up with the following keys and data: keys = [sn_keccak(“Transfer), 111, 222] and data = [333].

Extensibility and Components

This section discusses the second phase of the new syntax. As mentioned in the TL;DR, this part includes no breaking changes, and only enriches smart contract capabilities. Please note that the examples below are still experimental and should not be treated as finalized. Our goal here is to give the community an idea of how extensibility will be allowed in the near future; thus, while correct in spirit, the syntax and namings below may change.

So far, we’ve been discussing the safety features of the new syntax, allowing us to be explicit about the contract interface, storage, and state mutation. This still doesn’t solve the extensibility problem; how can our contract “inherit” storage/functionality from an external library in a way that is both safe and consistent with Cairo 1.0 syntax? To this end, we introduce the notion of components.

We will use the Ownable component as an example. We want our contract to have the is_owner(addr: ContractAddress) function, which compares addr to the owner address that is kept in storage. We somehow need to allow a contract with an arbitrary state to access its “ownable” part. This ability will come from the GetComponent trait which is part of the starknet core library:

trait GetComponent<ContractState, ComponentState> {
  fn component(self: ContractState) -> ComponentState;
  fn component_snap(self: @ContractState) -> @ComponentState;
}

This trait allows us to extract the ComponentState from ContractState, we will see below how this comes handy when implementing the component. We are now ready to write the code for the external library implementing Ownable as a component and the contract code using it.

// external_lib.cairo

#[starknet::contract_state(OwnableState)]
struct OwnableStorage {
   owner: ContractAddress
}

#[starknet::interface]
trait Ownable<TContractState> {
   fn is_owner(self: @TContractState, addr: ContractAddress) -> bool;
}

#[starknet::component]
impl OwnableImpl<TContractState, impl I: GetComponent<TContractState, OwnableState>> of Ownable<TContractState> {
   fn is_owner(self: @TContractState, addr: ContractAddress) –> bool {
      self.component_snap().owner.read() == addr  
   }
}

What is going on here? We see the component’s storage marked with the #[starknet::contract_state(...)] attribute, and the trait containing the is_owner function. The more sophisticated part is in the implementation itself. Let us examine the impl declaration:

impl OwnableImpl<
  TContractState, impl I: GetComponent<TContractState, OwnableState>,
> of Ownable<TContractState>

Note that the second generic parameter is an impl (in Cairo 1, we have generic impl parameters). For those concerned that this syntax is way too verbose, one can introduce a syntactic sugar where impls in generic arguments don’t have to be named. Additionally, since we’re not defining a contract here, we need to explicitly request the compiler to generate a state type that wraps storage accesses, this happens via the #[starknet::contract_state(OwnableState)] attribute.

Now that we understand the signature, we can ask why we should expect the generic OwnableImpl to depend on an implementation of GetComponent<TContractState, OwnableState>. To understand this, we need to look at the function body. OwnableImpl is generic and should work for arbitrary contracts, hence the generic parameter TContractState. However, since we know nothing about TContractState, it is unclear how to access the “ownable” parts of it. This is where the GetComponent impl kicks in. It gives us precisely the ability we need, extracting OwnableState from TContractState. When we write self.component_snap(), the compiler looks for impls that have a component_snap method whose first argument is of the same type as self, in this case TContractState. Luckily, it finds it in the GetComponent<TContractState, OwnableState> impl, I, on which OwnableImpl depends. Again we were able to utilize standard Cairo 1.0 behavior in our smart contract syntax.

We’re now ready to see what a contract that uses this component looks like:

// my_contract.cairo

 #[starknet::interface]
 trait MyContract<T> { … }

#[starknet::contract]
mod MyContract {
   use external_lib::{OwnableStorage, OwnableImpl};
  
   #[storage]
   struct Storage {
      #[component]
      ownable: OwnableStorage
   }
   
   #[external(v0)]
   impl MyContractU256 of MyContract<u256> { … }
   
   #[external(v0)]
   impl Ownable = OwnableImpl<ContractState>;
}

By adding one new attribute over a member of our storage and by marking OwnableImpl<ContractState> with the #[external(v0)] attribute, we have added the ownable functionality to MyContract. Let’s see how this works.

The #[component] attribute over ownable causes the compiler to generate an implementation of the GetComponent<ContractState, OwnableState> trait. Once we instantiate OwnableImpl with ContractState as the first generic parameter, the compiler deduces the impl for the second parameter (this is the autogenerated impl that given ContractState, returns OwnableState). That’s it! Since impl Ownable is marked with the #[external(v0)] attribute, we end up with the ownable functionality inside MyContract.

Summary

The contract syntax is evolving alongside the rest of Cairo’s features! We’ve seen how, following community feedback and our goal of maximizing explicitness and minimizing mistakes, several parts of the contract are now organized differently (external functions and events). These changes will be available soon, alongside the next Starknet version, alpha v0.12.0. We welcome you to raise any issues/concerns that you may have or simply ask questions via GitHub or our Cairo-1 discord.

If a contract written in version v1.10 is already running on the mainnet, does it mean that it will cease to function after an upgrade six months later? If so, are there any recommended methods for upgrading the contract?

Deployed Cairo 1 contracts will continue to work as usual, no effect there.

Well written overview, thanks! A few questions

  1. Can view functions access external contracts?

  2. If yes, can they change the external contracts’ state?

  3. What are event keys used for? Are event keys similar to Solidity’s “indexed” keyword? So one can filter events by their keys, but not by their data.

Hey,

Quick question/remark about the events:

Would it be relevant to make it shorter then, from:

#[event]
#[derive(starknet::Event)]
enum Event {
   CounterIncreased: CounterIncreased,
   CounterDecreased: CounterDecreased
}

to

#[event]
#[derive(starknet::Event)]
enum Event {
  :CounterIncreased,
  :CounterDecreased
}

Maybe the : aren’t even useful?
Or is it on enforced for some reason that the dev has to write both?

1 and 2 - good point! They can, and currently, they can call functions that get self by ref. I think it’s a good idea to disallow functions that take a snapshot of the state to call external functions that get the state by ref. We will do so eventually, however, in the current state where the Dispatcher is a standalone type it’s more problematic.

3 - yes, it’s completely analogous to Soldity’s indexed and also existed in Cairo 0, albeit only if you used the low-level syntax for emitting events, otherwise, you would only get one key corresponding to the event’s name.

That’s the enum syntax in Cairo 1; I think that adding some special treatment for the case where the variant name and variant type are the same would be weird.

But aren’t the name enforced to be the same anyway?

Or am I missing smthng?

In the future, we may have more flexibility here. Even in the current state, these mean different things (variant name and a type), so I don’t think it’s desirable to couple them implicitly.

What is the retro-compatibility for component storage? Is it still based on the name of the variable?

How internal functions that should not be called externally should be declared?

I’m guessing you’d just define them outside of the impl and they would then not have any ABI generated for them.

Thanks a lot for this post,

Overall I think these are great and necessary changes but I want to tackle a few points :

1- All of these changes do increase safety at the cost of readability and learning curve for developers. I think this should be thoroughly analyzed and personnaly I think it will be harder and harder to attract thousands of devs with this kind of synthax. I know synthatic sugar is not the priority but it should be considered heavily if we want the whole ecosystem to thrive.

For example, I really don’t want to see

#[event]
#[derive(Drop, starknet::Event)]

That’s so confusing for newcomers.

2- Regarding extensibility, I geniunely think this component approach is really clever and aims for the right thing but again these fancy generic types should be abstracted away inside the macro somehow.
Extensibility should not go further than being able to reuse other contracts’ easily.

How would I decide if I want to make my logic as a component or a contract ?

Personnaly, I’d totally embrace this but just trying to reason like a newcomer would.

3- The overall interoperability is a 10x improvement on anything else, the #[abi] is perfect and will solve a lot of issues.

Just want to spark off some debate there, keep up the good work, you’re killing it and excited for what’s to come #KeepStarknetStrange :fire:

Thank you for this amazing post and explanations!

This is a typo? Or is the code above wrong?

   #[starknet::impl(v0)] <<< Is this correct?
   impl Ownable = OwnableImpl<ContractState>;

Thanks again for this, diving into the new syntax!

About the events, is there a limit about the amount of members that can be a key?
Also, does it cost more to emit an event as key rather than data?

You can define them inside the contract module, but not under the impl marked as external. Note that if they touch the state, then you need to pass a ContractState argument. There is an advantage of defining such functions under an impl:

#[generate_trait]
impl PrivateFunctons of PrivateFunctionsTrait {
   fn is_even(self: ContractState) {
      self.counter.read() % 2 == 0 
   }
}

If you defined the above impl, then calling private functions would look like self.is_even().

Components are not yet finalized, but since the component’s storage will be explicit in your storage it can be based on name in the same way.

Typo, should be external(v0) just like in the counter contract. However, please note that the components section is still not finalized; the goal here is to demonstrate what is planned and give an example of how we can achieve it.

Good question! Currently, these are not priced. In the future, adding more keys/data will probably cost similarly to tx calldata (paying per byte), which today is not yet part of the fee mechanism (once it’s added, we can consider keys being slightly more expensive). Re the maximum number of keys, it is defined at the node level. Different nodes may work with different limits, but I don’t think you need a very large number here.

For those who don’t really care about the motivation and just want to get their code to compile, the docs offer a more concise guide for migrating.