In this section, we'll explore key syntaxes of the Cairo programming language, including differentiating between a Cairo program and a Starknet Contract!
A Cairo program is a sequence of instructions written in the Cairo programming language that specifies a set of computations to be executed. The difference between these and Starknet contracts are Cairo programs do not have access to Starknet's VM and are as such stateless.
A program must always have a main
entrypoint:
fn main() {}
A StarkNet contract, in very simple words are programs that runs on the Starknet VM. Since they run on the VM, they have access to Starknet’s persistent state, can alter or modify variables in Starknet’s states, communicate with other contracts, and interact seamlessly with the underlying L1.
Starknet contracts are denoted by the #[contract]
macro.
In the following code, we will analyze a simple ENS contract. This contract allows users to assign a name to their contract address. We will examine key concepts in the code such as imports, decorators, state variables, and how to read and write to the contract storage.
use starknet::ContractAddress;
#[starknet::interface]
trait IENS<TContractState> {
fn store_name(ref self: TContractState, _name: felt252);
fn get_name(self: @TContractState, _address: ContractAddress) -> felt252;
}
#[starknet::contract]
mod ENS {
use starknet::get_caller_address;
use starknet::ContractAddress;
#[storage]
struct Storage {
names: LegacyMap::<ContractAddress, felt252>,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
StoredName: StoredName
}
#[derive(Drop, starknet::Event)]
struct StoredName {
caller: ContractAddress,
name: felt252
}
#[constructor]
fn constructor(ref self: ContractState, _name: felt252, _address: ContractAddress) {
self.names.write(_address, _name);
}
#[external(v0)]
impl IENSImpl of super::IENS<ContractState> {
fn store_name(ref self: ContractState, _name: felt252) {
let caller = get_caller_address();
self.names.write(caller, _name);
self.emit(Event::StoredName(StoredName{ caller: caller, name: _name }));
}
fn get_name(self: @ContractState, _address: ContractAddress) -> felt252 {
let name = self.names.read(_address);
return name;
}
}
}
In StarkNet contracts, attributes are special annotations that modify the behavior of certain functions or methods. Attributes are placed before a function or method and begin with the #[
symbol.
The followings are attributes commonly used in StarkNet contracts:
#[starknet::contract]
: This attribute is used to mark a module as a StarkNet contract. It informs the compiler that the module should be compiled and deployed to the StarkNet network.
#[constructor]
: This attribute allows the function to be called with any necessary arguments to initialize the contract's state variables on deployment.
#[storage]
: This attribute is used to specify Storage structs.
#[starknet::interface]
: This attribute is used to specify contract interfaces.
#[external]
: This attribute is used to mark a function as an external function that can be called from outside the contract. External functions are typically used to modify the contract state or emit events.
#[event]
: Defines events that can be emitted by the contract
#[derive()]
: This attribute generates code to implement a default trait on the type you’ve annotated with the derive syntax.
#[l1_handler]
: This decorator is used for functions which receives a message from L1.
NB: It's worthy to note that with the recent updated syntax changes, It's now compulsory for all contracts to have an Interface which is implemented within the contract body.
Imports are statements that allow the contract to use external modules and their associated functionality. Import statements are typically placed at the top of a contract file, before the module definition.
we use the use
keyword to import the get_caller_address
and ContractAddress
from starknet's core library. The get_caller_address
method is used to retrieve the address of the caller and ContractAddress
generates the address type. By importing these functions, we make them available for use in our contract without having to define them ourselves.
use starknet::get_caller_address;
use starknet::ContractAddress;
Functions in Cairo 1.0 looks similar to the 0.x versions, with the omission of the implicit arguments and the change of the func keyword to fn following Rust’s convention.
With the new Cairo version, the external and view attributes are also no longer necessary as you can differentiate an external
function from a view
function by wether they accept a reference or a snapshot of the self
variable. external
function accepts a reference to self
, why view
functions accept a snapshot of self
(owing to the fact they shouldn't be able to mutate the Contract state).
Here’s an example of a basic external
function signature in Cairo 1.0:
fn store_name(ref self: ContractState, _name: felt252) {
...
}
Here’s an example of a basic view
function signature in Cairo 1.0:
fn get_name(self: @ContractState, _address: ContractAddress) -> felt252 {
...
}
Functions can also be annotated with specific attributes such as #[constructor]
, #[l1_handler]
etc, depending on the use case.
State variables are variables that store data that can be accessed and modified throughout the lifetime of the contract. These variables are declared within the struct
block of a module and are stored in the contract's persistent storage. State variables are stored in a struct named Storage
.
#[storage]
struct Storage{
age: u8,
names: LegacyMap::<ContractAddress, felt252>,
}
Mappings as you can see, are created using the LegacyMap
keyword, and specifying the data types of the mapped variables within <>
. You can create more complex mappings using tuples e.g
allowances: LegacyMap::<(ContractAddress, ContractAddress), u256>
which is the mapping of (owner, spender) -> balances
for an ERC20 token.
fn store_name(ref self: ContractState, _name: felt252) {
let caller = get_caller_address();
self.names.write(caller, _name);
self.emit(Event::StoredName(StoredName{ caller: caller, name: _name }));
}
The store_name
function is used to write a name to the contract storage for a given address. Here's how this function works:
- The
get_caller_address
function is used to retrieve the address of the caller, which is then stored in the caller variable. - The
self.names.write(caller, _name)
statement writes the given_name
value to the contract storage for the caller address. The names variable refers to a state variable of the Storage struct that stores the mapping of names to contract addresses. - The
self.emit(Event::StoredName(StoredName{ caller: caller, name: _name }))
statement emits aStoredName
event with the caller address and the_name
value as arguments. This event is emitted to the StarkNet network, allowing other contracts and external clients to receive notifications when aname
is stored in the contract storage.
Overall, the store_name
function allows a user to specify a name for their contract address and store it in the contract storage. By writing the name to the contract storage.
fn get_name(self: @ContractState, _address: ContractAddress) -> felt252 {
let name = self.names.read(_address);
return name;
}
-
The
get_name
function is used to read aname
from the contract storage for a given contract address. Here's how this function works: -
The
self
parameter holds a snapshot of the Contract State. -
The
_address
parameter specifies thecontract address
for which to retrieve the name. -
The
self.names.read(_address)
statement reads thename
from the contract storage for the specified_address
. Thenames
variable refers to a state variable of the Storage struct that stores the mapping ofnames
to contract addresses. -
The
name
variable stores the value that was read from the contract storage. -
Finally, the
name
variable is returned to thecaller
of the function.
PS: For now, ignore the events, we'll look into that in details in chapter 8