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.