A simple contract

This chapter will introduce you to the basics of Starknet contracts with an example of a basic contract. You will learn how to write a simple contract that stores a single number on the blockchain.

Anatomy of a simple Starknet Contract

Let's consider the following contract to present the basics of a Starknet contract. It might not be easy to understand it all at once, but we will go through it step by step:

#![allow(unused)]
fn main() {
#[starknet::interface]
trait ISimpleStorage<TContractState> {
    fn set(ref self: TContractState, x: u128);
    fn get(self: @TContractState) -> u128;
}

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

    #[storage]
    struct Storage {
        stored_data: u128
    }

    #[external(v0)]
    impl SimpleStorage of super::ISimpleStorage<ContractState> {
        fn set(ref self: ContractState, x: u128) {
            self.stored_data.write(x);
        }
        fn get(self: @ContractState) -> u128 {
            self.stored_data.read()
        }
    }
}
}

Listing 99-1: A simple storage contract

Note: Starknet contracts are defined within modules.

What is this contract?

In this example, the Storage struct declares a storage variable called stored_data of type u128 (unsigned integer of 128 bits). You can think of it as a single slot in a database that you can query and alter by calling functions of the code that manages the database. The contract defines and exposes publicly the functions set and get that can be used to modify or retrieve the value of that variable.

The Interface: the contract's blueprint

#[starknet::interface]
trait ISimpleStorage<TContractState> {
    fn set(ref self: TContractState, x: u128);
    fn get(self: @TContractState) -> u128;
}

The interface of a contract represents the functions this contract exposes to the outside world. Here, the interface exposes two functions: set and get. By leveraging the traits & impls mechanism from Cairo, we can make sure that the actual implementation of the contract matches its interface. In fact, you will get a compilation error if your contract doesn’t conform with the declared interface.

    #[external(v0)]
    impl SimpleStorage of super::ISimpleStorage<ContractState> {
        fn set(ref self: ContractState) {}
        fn get(self: @ContractState) -> u128 {
            self.stored_data.read()
        }
    }

Listing 99-1-bis: A wrong implementation of the interface of the contract. This does not compile.

In the interface, note the generic type TContractState of the self argument which is passed by reference to the set function. The self parameter represents the contract state. Seeing the self argument passed to set tells us that this function might access the state of the contract, as it is what gives us access to the contract’s storage. The ref modifier implies that self may be modified, meaning that the storage variables of the contract may be modified inside the set function.

On the other hand, get takes 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 the get function).

Public functions are defined in an implementation block

Before we explore things further down, let's define some terminology.

  • In the context of Starknet, a public function is a function that is exposed to the outside world. In the example above, set and get are public functions. A public function can be called by anyone, and can be called from outside the contract, or from within the contract. In the example above, set and get are public functions.

  • What we call an external function is a public function that is invoked through a transaction and that can mutate the state of the contract. set is an external function.

  • A view function is a public function that can be called from outside the contract, but that cannot mutate the state of the contract. get is a view function.

    #[external(v0)]
    impl SimpleStorage of super::ISimpleStorage<ContractState> {
        fn set(ref self: ContractState, x: u128) {
            self.stored_data.write(x);
        }
        fn get(self: @ContractState) -> u128 {
            self.stored_data.read()
        }
    }

Since the contract interface is defined as the ISimpleStorage trait, in order to match the interface, the external functions of the contract must be defined in an implementation of this trait — which allows us to make sure that the implementation of the contract matches its interface.

However, simply defining the functions in the implementation is not enough. The implementation block must be annotated with the #[external(v0)] attribute. This attribute exposes the functions defined in this implementation to the outside world — forget to add it and your functions will not be callable from the outside. All functions defined in a block marked as #[external(v0)] are consequently public functions.

When writing the implementation of the interface, 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 in 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.

Modifying the contract's state

As you can notice, all functions that need to access the state of the contract are defined under the implementation of a trait that has a TContractState generic parameter, and take a self: ContractState parameter. This allows us to explicitly pass the self: ContractState parameter to the function, allowing access the storage variables of the contract. To access a storage variable of the current contract, you add the self prefix to the storage variable name, which allows you to use the read and write methods to either read or write the value of the storage variable.

        fn set(ref self: ContractState, x: u128) {
            self.stored_data.write(x);
        }

Using self and the write method to modify the value of a storage variable

Note: if the contract state is passed as a snapshot instead of ref, attempting to modify will result in a compilation error.

This contract does not do much yet apart from allowing anyone to store a single number that is accessible by anyone in the world. Anyone could call set again with a different value and overwrite your number, but the number is still stored in the history of the blockchain. Later, you will see how you can impose access restrictions so that only you can alter the number.

Last change: 2023-11-21, commit: 2fbb62a