The Improved Starknet Syntax

David Barreto
Starknet Edu
Published in
7 min readJun 29, 2023

TL;DR; The version 2 of the Cairo compiler brings changes to the Starknet syntax that makes the code more explicit and safer. The smart contract public interface is defined with a trait and access to storage is done via the ContractState trait. Private methods must be defined using a different implementation from the public interface. Events are now defined as enums where each variant is a struct of the same name.

Disclaimer: The terminology used here to refer to different versions of the Cairo compiler and its syntax is temporary as the Starknet community is still discussing what’s the best terminology to use. Once settled, this article will be updated accordingly.

The Compiler v2

Just last week a new major version of the Cairo compiler, version 2.0.0-rc0 was released on Github. The new compiler has brought significant improvements to the Starknet plugin to make our code safer, more explicit and more reusable. Keep in mind that this new version of the compiler is not yet supported on Starknet testnet or mainnet as it is still just making its way through the integration environment.

The goal of this article is to show you how to rewrite a Starknet smart contract created for the Cairo compiler version 1.x into a smart contract compatible with the compiler version 2.x. Our starting point will be the “Ownable” smart contract created in a previous article that is compatible with the Cario compiler version 1.x.

#[contract]
mod Ownable {
use starknet::ContractAddress;
use starknet::get_caller_address;

#[event]
fn OwnershipTransferred(previous_owner: ContractAddress, new_owner: ContractAddress) {}

struct Storage {
owner: ContractAddress,
}

#[constructor]
fn constructor(init_owner: ContractAddress) {
owner::write(init_owner);
}

#[view]
fn get_owner() -> ContractAddress {
owner::read()
}

#[external]
fn transfer_ownership(new_owner: ContractAddress) {
only_owner();
let previous_owner = owner::read();
owner::write(new_owner);
OwnershipTransferred(previous_owner, new_owner);
}

fn only_owner() {
let caller = get_caller_address();
assert(caller == owner::read(), 'Caller is not the owner');
}
}

Project Setup

Because Protostar doesn’t yet support the compiler v2, I’ll rely on a pre-release version of Scarb (version 0.5.1) that does support it. To install that specific version of Scarb you can use the command below.

$ curl --proto '=https' --tlsv1.2 -sSf https://docs.swmansion.com/scarb/install.sh | bash -s -- -v 0.5.1

After the installation completes, verify you got the right version.

$ scarb --version
>>>
scarb 0.5.1 (798acce7f 2023-07-05)
cairo: 2.0.1 (https://crates.io/crates/cairo-lang-compiler/2.0.1)

We can now create a Scarb project for our tutorial.

$ scarb new cairo1_v2
$ cd cairo1_v2

You should get the folder structure shown below.

$ tree .
>>>
.
├── Scarb.toml
└── src
└── lib.cairo

To allow Scarb to compile Starknet smart contracts, we need to enable the Starknet plugin as a dependency.

// Scarb.toml

[package]
name = "cairo1_v2"
version = "0.1.0"

[dependencies]
starknet = ">=2.0.1"

[cairo]
sierra-replace-ids = true

[[target.starknet-contract]]
sierra = true

With the setup complete we can head over to src/lib.cairo to start writing the smart contract.

Storage & Constructor

In version 2 of the Cairo compiler, smart contracts are still defined by modules annotated with the “contract” attribute only that this time the attribute is namespaced with the name of the plugin where it was defined, “starknet” in this case.

#[starknet::contract]
mod Ownable {}

The internal storage is still defined with a struct that must be called “Storage” only this time there’s a storage attribute that must be used to annotate it.

#[starknet::contract]
mod Ownable {
use super::ContractAddress;

#[storage]
struct Storage {
owner: ContractAddress,
}
}

To define a constructor we use the constructor attribute to annotate the function as we did in v1 with the advantage that now the function can have any name, it doesn’t need to be called “constructor” as in v1. Even though it’s not required, I’ll still call the function “constructor” out of habit but you can call it differently.

Another important change is that now the constructor function is automatically passed a reference to ContractState which acts as a mediator for your storage variables, in this case “owner”.

#[starknet::contract]
mod Ownable {
use super::ContractAddress;

#[storage]
struct Storage {
owner: ContractAddress,
}

#[constructor]
fn constructor(ref self: ContractState, init_owner: ContractAddress) {
self.owner.write(init_owner);
}
}

Notice that the syntax to write and read to storage has changed from v1. Before we did owner::write() while now we do self.owner.write(). The same applies for reading from storage.

By the way, the type ContractState does not need to be manually brought to scope, it’s included in the prelude.

Public Methods

An important difference from version 1 of the Cairo compiler, is that now we need to explicitly define the public interface of our smart contract using a trait annotated with the starknet::interface attribute.

use starknet::ContractAddress;

#[starknet::interface]
trait OwnableTrait<T> {
fn transfer_ownership(ref self: T, new_owner: ContractAddress);
fn get_owner(self: @T) -> ContractAddress;
}

#[starknet::contract]
mod Ownable {
...
}

If you recall our original code in v1, our smart contract had two “public” methods (get_owner and transfer_ownership) and one “private” (only_owner). This trait only deals with public methods without relying on the attributes “external” or “view” to denote which one is allowed to modify the contract’s state and which one isn’t. Instead this is now explicit by the type of the parameter self.

If a method expects a reference to the ContractStorage (that’s what the generic T will be once implemented) the method is able to modify the internal state of the smart contract. This is what we used to call an “external” method. On the other hand, if a method expects a snapshot of the ContractStorage, then it can only read it but not modify it. This is what we used to call a “view” method.

We can now create an implementation for the trait we just defined using the keyword impl. Remember that Cairo differs from Rust in that implementations have names.

use starknet::ContractAddress;

#[starknet::interface]
trait OwnableTrait<T> {
fn transfer_ownership(ref self: T, new_owner: ContractAddress);
fn get_owner(self: @T) -> ContractAddress;
}

#[starknet::contract]
mod Ownable {
...
#[external(v0)]
impl OwnableImpl of super::OwnableTrait<ContractState> {
fn transfer_ownership(ref self: ContractState, new_owner: ContractAddress) {
let prev_owner = self.owner.read();
self.owner.write(new_owner);
}

fn get_owner(self: @ContractState) -> ContractAddress {
self.owner.read()
}
}
}

We have created an implementation for our trait inside the module that defines the smart contract, passing the type ContractState as the generic type T so our methods have access to storage just like the constructor does.

Our implementation is annotated with the attribute external(v0). The version 0 in the attribute means that selectors are derived exclusively from the method name as has been the case in the past. The downside is that, if you define a second implementation for your smart contract for a different trait and both traits happen to use the same name for one of its methods, the compiler will throw an error due to selector duplication.

Future versions of the attribute might add a new way to calculate selectors to prevent collisions but that’s not available yet. For now, we can only use version 0 of the external attribute.

Private Methods

There’s one more method that we need to define for our smart contract, only_owner. This method checks that whoever is calling it should be the owner of the smart contract.

Because this is a private method that it’s not meant to be called from the outside, it can’t be defined as part of the OwnableTrait which is our smart contract’s public interface. Instead, we are going to create a new implementation of an automatically generated trait using the attribute generate_trait.

...
#[starknet::contract]
mod Ownable {
...
#[generate_trait]
impl PrivateMethods of PrivateMethodsTrait {
fn only_owner(self: @ContractState) {
let caller = get_caller_address();
assert(caller == self.owner.read(), 'Caller is not the owner');
}
}
}

I can now use the only_owner method by calling self.only_owner() where needed.

#[starknet::contract]
mod Ownable {
...
#[external(v0)]
impl OwnableImpl of super::OwnableTrait<ContractState> {
fn transfer_ownership(ref self: ContractState, new_owner: ContractAddress) {
self.only_owner();
...
}
...
}

#[generate_trait]
impl PrivateMethods of PrivateMethodsTrait {
fn only_owner(self: @ContractState) {
...
}
}
}

Events

Whereas in Cairo v1 an event was just a function with no body and annotated with the attribute event, for v2 an event is an enum annotated with the same attribute but now implementing some additional traits using derive.

...
#[starknet::contract]
mod Ownable {
...
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
OwnershipTransferred: OwnershipTransferred,
}

#[derive(Drop, starknet::Event)]
struct OwnershipTransferred {
#[key]
prev_owner: ContractAddress,
#[key]
new_owner: ContractAddress,
}
}

Each variant of the Event enum must be a struct of the same name. The struct is where we define all the values we want to emit using the optional key attribute to inform the system which values we want Starknet to index for faster search and retrieval by indexers. In this case we want both values (prev_owner and new_owner) to be indexed.

The ContractState trait defines an emit method that can be used to emit our event.

...
#[starknet::contract]
mod Ownable {
...
#[external(v0)]
impl OwnableImpl of super::OwnableTrait<ContractState> {
fn transfer_ownership(ref self: ContractState, new_owner: ContractAddress) {
...
self.emit(Event::OwnershipTransferred(OwnershipTransferred {
prev_owner: prev_owner,
new_owner: new_owner,
}));
}
...
}
...
}

With this final feature we have completed the migration of our Ownable smart contract from v1 to v2. The complete code is shown below.

use starknet::ContractAddress;

#[starknet::interface]
trait OwnableTrait<T> {
fn transfer_ownership(ref self: T, new_owner: ContractAddress);
fn get_owner(self: @T) -> ContractAddress;
}

#[starknet::contract]
mod Ownable {
use super::ContractAddress;
use starknet::get_caller_address;

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

#[derive(Drop, starknet::Event)]
struct OwnershipTransferred {
#[key]
prev_owner: ContractAddress,
#[key]
new_owner: ContractAddress,
}

#[storage]
struct Storage {
owner: ContractAddress,
}

#[constructor]
fn constructor(ref self: ContractState, init_owner: ContractAddress) {
self.owner.write(init_owner);
}

#[external(v0)]
impl OwnableImpl of super::OwnableTrait<ContractState> {
fn transfer_ownership(ref self: ContractState, new_owner: ContractAddress) {
self.only_owner();
let prev_owner = self.owner.read();
self.owner.write(new_owner);
self.emit(Event::OwnershipTransferred(OwnershipTransferred {
prev_owner: prev_owner,
new_owner: new_owner,
}));
}

fn get_owner(self: @ContractState) -> ContractAddress {
self.owner.read()
}
}

#[generate_trait]
impl PrivateMethods of PrivateMethodsTrait {
fn only_owner(self: @ContractState) {
let caller = get_caller_address();
assert(caller == self.owner.read(), 'Caller is not the owner');
}
}
}

You can also find this code on Github.

Conclusion

The new syntax for Starknet brought up by the version 2 of the Cairo compiler, makes our smart contract’s code to look more inline with Cairo itself and more similar to Rust by extension. Even at the expense of having more verbose code, the advantages in safety are worth the tradeoff.

In this article we didn’t touch on everything about the new syntax, specifically how to interact with other smart contracts but you can learn more by reading the changelog of the compiler, reading this article from the forum or watching this video from StarkWare’s YouTube channel.

This new version of the compiler will be made available to Starknet’s testnet in a few weeks and then on Mainnet a few weeks after that so don’t try to deploy this code just yet, it won’t work.

Cairo just keeps getting better.

Resources

--

--

David Barreto
Starknet Edu

Starknet Developer Advocate. Find me on Twitter as @barretodavid