Testing a Cairo 1 Smart Contract with Protostar

David Barreto
Starknet Edu
Published in
13 min readJun 19, 2023
The testing pyramid

TL;DR; Protostar is a test runner for vanilla Cairo and for Starknet smart contracts that depends on Scarb for compilation and managing dependencies. Protostar allows you to create integration tests for Starknet smart contracts using “cheatcodes” but its execution is slow and doesn’t have a way to test events. Software Mansion is already working on a Rust rewrite to improve performance and on a new cheatcode to test events.

Protostar

The tooling for Starknet is finally catching up with the new version of the Cairo programming language that is heavily inspired by Rust. Protostar, developed by Software Mansion, is one of such tools and probably my favorite one when it comes to testing Starknet smart contracts written in Cairo 1.

Protostar follows a similar approach to Foundry when it comes to testing as it makes use of “cheatcodes” to control how the mock instance of the network, Starknet in this case, should behave to enable more complex test scenarios.

In this article I’ll show you how to create an Ownable smart contract with Cairo 1 and how to effectively write integration tests for it using Protostar. To follow along make sure to have Protostar installed.

Project Setup

To create a Starknet project with Protostar you can use the command protostar init with the flag — minimal to have a simplified folder structure that’s best suited for a tutorial.

$ protostar init --minimal owner_manager
$ cd owner_manager

The command creates the folder structure shown below.

$ tree .
>>>
.
├── Scarb.toml
├── protostar.toml
├── src
│ └── lib.cairo
└── tests
└── test_hello_starknet.cairo

Because Protostar depends on Scarb for compilation and for managing dependencies your project gets two different configuration files, Scarb.toml and protostar.toml.

This is how Scarb.toml looks out of the box.

// Scarb.toml

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

[dependencies]

hello_starknet is not a very descriptive name for our project so let’s change it to protostar_tutorial. Because we won’t use dependencies for this tutorial, we can go ahead and remove that section as well to simplify it further.

// Scarb.toml

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

Let’s now take a look at Protostar’s configuration file.

// protostar.toml

[project]
protostar-version = "0.13.0"

[contracts]
hello_starknet = ["src"]

The keys under the contracts section is how our smart contracts will be referred to in our test suite. hello_starknet is not a great name for our ownable smart contract so let’s update it.

// protostar.toml

[project]
protostar-version = "0.13.0"

[contracts]
ownable = ["src"]

Protostar projects come with an example smart contract and a couple of integration tests for it that we are not going to use as we will write our own. We can remove both while keeping the general structure.

// src/lib.cairo

#[contract]
mod Ownable {}
// tests/test_ownable.cairo

use array::ArrayTrait;
use result::ResultTrait;

#[test]
fn test_description() {}

As you can see, I’ve also modified the name of the test file from test_hello_starknet.cairo to test_ownable.cairo for consistency with the rest of our project.

We are done with the setup, we are ready to start writing our Ownable smart contract.

The Ownable Smart Contract

Our smart contract will keep track of who its owner is and it will allow the owner to transfer ownership to someone else while preventing strangers from doing so. The first step is to keep track of the current owner using a storage variable.

// src/lib.cairo

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

struct Storage {
owner: ContractAddress,
}
}

ContractAddress is just a type alias for felt252 and it is used to make more explicit the intention of your code.

Next, we are going to make the deployer of the smart contract its default owner.

// src/lib.cairo

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

struct Storage {
owner: ContractAddress,
}

#[constructor]
fn constructor() {
let deployer = get_caller_address();
owner::write(deployer);
}
}

To know who the deployer is, we used the syscall get_caller_address and stored its returned value to storage.

To allow anyone to know who the owner of the smart contract is, we are going to create a view function (read-only) to return the stored address.

// src/lib.cairo

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

struct Storage {
owner: ContractAddress,
}

#[constructor]
fn constructor() {
let deployer = get_caller_address();
owner::write(deployer);
}

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

We have the basic structure to start writing some integration tests.

Writing an Integration Test

The focus of our first test is to make sure that the deployer of our smart contract actually becomes its owner.

Normally you would use the cheatcode deploy_contract to declare and deploy a smart contract in a single step like shown below.

use array::ArrayTrait;
use result::ResultTrait;

#[test]
fn test_deployer_is_owner() {
let constructor_args = ArrayTrait::new();
let contract_address = deploy_contract('owner', @constructor_args).unwrap();
....
}

The problem is that we also want to use the cheatcode start_prank to set the address of the deployer account before the constructor of our smart contract is executed and the syscall get_caller_address is invoked, and the prank cheatcode requires the address of the target smart contract as you can see in its function signature.

fn start_prank(caller_address: felt252, target_contract_address: felt252) -> Result::<(), felt252> nopanic;

In this scenario, Protostar provides individual cheatcodes for declaring, preparing and deploying a smart contract. The declare cheatcode calculates the class_hash, prepare calculates the address and deploy deploys the smart contract and runs the constructor function.

Below you can find the signatures for the cheatcodes declare, prepare and deploy.

fn declare(contract: felt252) -> Result::<felt252, felt252> nopanic;

fn prepare(class_hash: felt252, calldata: @Array::<felt252>) -> Result::<PreparedContract, felt252> nopanic;

fn deploy(prepared_contract: PreparedContract) -> Result::<felt252, RevertedTransaction> nopanic;

Using these cheatcodes, we can now deploy our smart contract in our test using multiple steps.

...
#[test]
fn test_deployer_is_owner() {
let deployer_address = 123;
let class_hash = declare('ownable').unwrap();
let constructor_args = ArrayTrait::new();
let prepared = prepare(class_hash, @constructor_args).unwrap();
let contract_address = deploy(prepared).unwrap();
}

But wait, before calling the deploy cheatcode and our constructor function gets executed, we need to trick the system into thinking that the deployer address is “123” by using the start_prank and stop_prank cheatcodes.

...
#[test]
fn test_deployer_is_owner() {
let deployer_address = 123;
let class_hash = declare('ownable').unwrap();
let constructor_args = ArrayTrait::new();
let prepared = prepare(class_hash, @constructor_args).unwrap();

start_prank(deployer_address, prepared.contract_address);
let contract_address = deploy(prepared).unwrap();
stop_prank(contract_address);
}

To make sure that the trick works and that our smart contract is assigning ownership to the deployer address, we can call the get_owner function and verify it returns the value “123” with an assert.

...
#[test]
fn test_deployer_is_owner() {
...
let function_args = ArrayTrait::new();
let returned = call(contract_address, 'get_owner', @function_args).unwrap();
let owner_address = *returned.at(0_u32);

assert(owner_address == deployer_address, 'The owner is not the deployer');
}

This is how the full integration test would look like at this stage:

use array::ArrayTrait;
use result::ResultTrait;

#[test]
fn test_deployer_is_owner() {
let deployer_address = 123;
let class_hash = declare('ownable').unwrap();
let constructor_args = ArrayTrait::new();
let prepared = prepare(class_hash, @constructor_args).unwrap();

start_prank(deployer_address, prepared.contract_address);
let contract_address = deploy(prepared).unwrap();
stop_prank(contract_address);

let function_args = ArrayTrait::new();
let returned = call(contract_address, 'get_owner', @function_args).unwrap();
let owner_address = *returned.at(0_u32);

assert(owner_address == deployer_address, 'The owner is not the deployer');
}

We can verify that it works by executing our test suite.

$ protostar test
>>>
Collected 1 suite, and 1 test case (2.59)
[PASS] tests/test_ownable.cairo test_deployer_is_owner (time=9.56s)
Test suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Seed: 2096609854
09:11:20 [INFO] Execution time: 26.83 s

It works. Let’s now add new functionality to allow the owner of the smart contract to transfer ownership to another address.

Transferring Ownership

Because transferring ownership requires changing the value of the owner storage variable, we need to create an external function that will require paying gas fees to execute.

#[contract]
mod Ownable {
...
#[external]
fn transfer_ownership(new_owner: ContractAddress) {
owner::write(new_owner);
}
}

We are going to add a new test to our test suite to verify that ownership can in fact be transferred. We start as before by using a cheatcode to set a default owner when deploying the smart contract.

...
#[test]
fn test_deployer_is_owner() {...}

#[test]
fn test_owner_can_transfer_ownership() {
let deployer_address = 123;
let class_hash = declare('ownable').unwrap();
let constructor_args = ArrayTrait::new();
let prepared = prepare(class_hash, @constructor_args).unwrap();

start_prank(deployer_address, prepared.contract_address);
let contract_address = deploy(prepared).unwrap();
stop_prank(contract_address);
}

Next we attempt to call the external function using the invoke cheatcode to transfer ownership to the address “456” while tricking the system into thinking that it’s the owner who’s invoking it.

...
#[test]
fn test_owner_can_transfer_ownership() {
...
let new_owner = 456;
let mut function_args = ArrayTrait::new();
function_args.append(new_owner);

start_prank(deployer_address, contract_address);
invoke(contract_address, 'transfer_ownership', @function_args);
stop_prank(contract_address);
}

If all went well, calling the function get_owner should return “456” instead of “123”.

...
#[test]
fn test_owner_can_transfer_ownership() {
...
let function_args = ArrayTrait::new();
let returned = call(contract_address, 'get_owner', @function_args).unwrap();
let contract_owner = *returned.at(0_u32);

assert(contract_owner == new_owner, 'Ownership was not transferred');
}

In all its glory, our new test looks like this:

...
#[test]
fn test_owner_can_transfer_ownership() {
let deployer_address = 123;
let class_hash = declare('ownable').unwrap();
let constructor_args = ArrayTrait::new();
let prepared = prepare(class_hash, @constructor_args).unwrap();

start_prank(deployer_address, prepared.contract_address);
let contract_address = deploy(prepared).unwrap();
stop_prank(contract_address);

let new_owner = 456;
let mut function_args = ArrayTrait::new();
function_args.append(new_owner);

start_prank(deployer_address, contract_address);
invoke(contract_address, 'transfer_ownership', @function_args);
stop_prank(contract_address);

let function_args = ArrayTrait::new();
let returned = call(contract_address, 'get_owner', @function_args).unwrap();
let contract_owner = *returned.at(0_u32);

assert(contract_owner == new_owner, 'Ownership was not transferred');
}

Let’s try executing our test suite to verify it works.

$ protostar test
>>>
Collected 1 suite, and 2 test cases (2.43)
[PASS] tests/test_ownable.cairo test_owner_can_transfer_ownership (time=10.70s)
[PASS] tests/test_ownable.cairo test_deployer_is_owner (time=7.00s)
Test suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Seed: 3744589294
10:10:40 [INFO] Execution time: 30.76 s

Both tests are passing. Good.

You have probably noticed that both of our tests start the exact same way by declaring, preparing, deploying and pranking the network.

...
#[test]
fn test_deployer_is_owner() {
let deployer_address = 123;
let class_hash = declare('ownable').unwrap();
let constructor_args = ArrayTrait::new();
let prepared = prepare(class_hash, @constructor_args).unwrap();

start_prank(deployer_address, prepared.contract_address);
let contract_address = deploy(prepared).unwrap();
stop_prank(contract_address);
...
}

#[test]
fn test_owner_can_transfer_ownership() {
let deployer_address = 123;
let class_hash = declare('ownable').unwrap();
let constructor_args = ArrayTrait::new();
let prepared = prepare(class_hash, @constructor_args).unwrap();

start_prank(deployer_address, prepared.contract_address);
let contract_address = deploy(prepared).unwrap();
stop_prank(contract_address);
...
}

To avoid repeating ourselves and simplify our test suite, we can write a setup function that performs the duplicated task.

fn setup(deployer_address: felt252) -> felt252 {
let class_hash = declare('ownable').unwrap();
let constructor_args = ArrayTrait::new();
let prepared = prepare(class_hash, @constructor_args).unwrap();

start_prank(deployer_address, prepared.contract_address);
let contract_address = deploy(prepared).unwrap();
stop_prank(contract_address);

return contract_address;
}

We can now rewrite our two test cases to use the setup function.

#[test]
fn test_deployer_is_owner() {
let deployer_address = 123;
let contract_address = setup(deployer_address);
...
}

#[test]
fn test_owner_can_transfer_ownership() {
let deployer_address = 123;
let contract_address = setup(deployer_address);
...
}

This is how our full test suite looks like:

use array::ArrayTrait;
use result::ResultTrait;

fn setup(deployer_address: felt252) -> felt252 {
let class_hash = declare('ownable').unwrap();
let constructor_args = ArrayTrait::new();
let prepared = prepare(class_hash, @constructor_args).unwrap();

start_prank(deployer_address, prepared.contract_address);
let contract_address = deploy(prepared).unwrap();
stop_prank(contract_address);

return contract_address;
}

#[test]
fn test_deployer_is_owner() {
let deployer_address = 123;
let contract_address = setup(deployer_address);

let function_args = ArrayTrait::new();
let returned = call(contract_address, 'get_owner', @function_args).unwrap();
let owner_address = *returned.at(0_u32);

assert(owner_address == deployer_address, 'The owner is not the deployer');
}

#[test]
fn test_owner_can_transfer_ownership() {
let deployer_address = 123;
let contract_address = setup(deployer_address);

let new_owner = 456;
let mut function_args = ArrayTrait::new();
function_args.append(new_owner);

start_prank(deployer_address, contract_address);
invoke(contract_address, 'transfer_ownership', @function_args);
stop_prank(contract_address);

let function_args = ArrayTrait::new();
let returned = call(contract_address, 'get_owner', @function_args).unwrap();
let contract_owner = *returned.at(0_u32);

assert(contract_owner == new_owner, 'Ownership was not transferred');
}

Verify that the rewrite works by re-executing the test suite.

We should write one last test to verify that if any address besides the owner tries to transfer ownership, the transaction should fail. This is a different type of test it will pass only if the transaction fails.

Testing that a Transaction Fails

The general structure of this new test is very similar to the previous one but in this scenario is not the owner trying to transfer ownership to someone else, it’s a hacker trying to transfer ownership to himself.

...
#[test]
fn test_only_owner_can_transfer_ownership() {
let deployer_address = 123;
let contract_address = setup(deployer_address);

let hacker = 456;
let mut function_args = ArrayTrait::new();
function_args.append(hacker);

start_prank(hacker, contract_address);
invoke(contract_address, 'transfer_ownership', @function_args);
stop_prank(contract_address);
}

Where do we place the assert if we want to check if executing transfer_ownership fails under these conditions? If you look at invoke’s function signature, you can see that it actually returns a Result enum.

fn invoke(contract_address: felt252, function_name: felt252, calldata: @Array::<felt252>) -> Result::<(), RevertedTransaction> nopanic;

When a function returns an enum, you can use pattern matching to handle both variants: Result::Ok when the transaction completes successfully and Result::Err when the transaction fails and gets reverted.

...
#[test]
fn test_only_owner_can_transfer_ownership() {
...
start_prank(hacker, contract_address);
match invoke(contract_address, 'transfer_ownership', @function_args) {
Result::Ok(x) => assert(false, 'Should not transfer ownership'),
Result::Err(_) => assert(true, ''),
};
stop_prank(contract_address);
}

If our smart contract is written correctly, the arm for Result::Err should be activated and the function assert(true, ‘’) executed thus passing the test. Let’s see if that’s what happens when we execute our test suite.

$ protostar test
>>>
Collected 1 suite, and 3 test cases (4.03)
[FAIL] tests/test_ownable.cairo test_only_owner_can_transfer_ownership (time=14.17s)
Test failed with data:
[2248673903150631978740482198030632356332645035283616605449677099788656] (integer representation)
['Should not transfer ownership'] (short-string representation)
...

Our test is failing because the arm Result::Ok is the one being executed when in theory the transaction should fail to prevent the hacker from stealing ownership of the smart contract.

If we look at how the transfer_ownership function is implemented we can clearly see why the code is not doing what it is supposed to do.

#[contract]
mod Ownable {
...
#[external]
fn transfer_ownership(new_owner: ContractAddress) {
owner::write(new_owner);
}
}

There’s no protection in place, anyone calling the function can steal ownership of the smart contract but we can change that.

#[contract]
mod Ownable {
...
#[external]
fn transfer_ownership(new_owner: ContractAddress) {
let caller = get_caller_address();
let current_owner = owner::read();
assert(caller == current_owner, 'Caller is not the owner');
owner::write(new_owner);
}
}

Let’s try running our test suite again.

$ protostar test
>>>
Collected 1 suite, and 3 test cases (2.88)
[PASS] tests/test_ownable.cairo test_only_owner_can_transfer_ownership (time=11.25s)
[PASS] tests/test_ownable.cairo test_deployer_is_owner (time=7.21s)
[PASS] tests/test_ownable.cairo test_owner_can_transfer_ownership (time=7.09s)
Test suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Seed: 354026808
14:18:06 [INFO] Execution time: 38.88 s

Now our test suite is passing, proving that the smart contract adheres to the expected behavior.

Verifying ownership at the beginning of a function is such a common pattern that it’s worthwhile to move this logic to its own private function for reusability.

#[contract]
mod Ownable {
...
#[external]
fn transfer_ownership(new_owner: ContractAddress) {
only_owner();
owner::write(new_owner);
}

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

After every refactoring of our code, it’s a good practice to re-run the test suite to verify that we didn’t change the behavior of the smart contract unintentionally.

Checking the Error Message

One final improvement to our test suite is to verify that when a transaction fails, the correct error message is logged.

Currently we are ignoring the value passed to the Result:Err arm of our pattern matching using an underscore as a placeholder which is just a convention not a special keyword.

#[test]
fn test_only_owner_can_transfer_ownership() {
...
match invoke(contract_address, 'transfer_ownership', @function_args) {
Result::Ok(x) => assert(false, 'Should not transfer ownership'),
Result::Err(_) => assert(true, ''),
};
...
}

If you look again at the function signature of the invoke cheatcode, you’ll notice that the Err invariant of the Result enum includes a value of type RevertedTransaction.

fn invoke(contract_address: felt252, function_name: felt252, calldata: @Array::<felt252>) -> Result::<(), RevertedTransaction> nopanic;

And this type implements the trait RevertedTransactionTrait that includes the method first which returns the error message thrown by the smart contract.

trait RevertedTransactionTrait {
fn first(self: @RevertedTransaction) -> felt252;
}

Let’s use these facts to check that the correct error message is being thrown when the transaction fails.

use cheatcodes::RevertedTransactionTrait;
...
fn test_only_owner_can_transfer_ownership() {
...
match invoke(contract_address, 'transfer_ownership', @function_args) {
Result::Ok(x) => assert(false, 'Should not transfer ownership'),
Result::Err(x) => assert(x.first() == 'Caller is not the owner', 'Incorrect error message'),
};
...
}

With this final change, our whole test suite should look like this:

use array::ArrayTrait;
use result::ResultTrait;
use cheatcodes::RevertedTransactionTrait;

fn setup(deployer_address: felt252) -> felt252 {
let class_hash = declare('ownable').unwrap();
let constructor_args = ArrayTrait::new();
let prepared = prepare(class_hash, @constructor_args).unwrap();

start_prank(deployer_address, prepared.contract_address);
let contract_address = deploy(prepared).unwrap();
stop_prank(contract_address);

return contract_address;
}

#[test]
fn test_deployer_is_owner() {
let deployer_address = 123;
let contract_address = setup(deployer_address);

let function_args = ArrayTrait::new();
let returned = call(contract_address, 'get_owner', @function_args).unwrap();
let owner_address = *returned.at(0_u32);

assert(owner_address == deployer_address, 'The owner is not the deployer');
}

#[test]
fn test_owner_can_transfer_ownership() {
let deployer_address = 123;
let contract_address = setup(deployer_address);

let new_owner = 456;
let mut function_args = ArrayTrait::new();
function_args.append(new_owner);

start_prank(deployer_address, contract_address);
invoke(contract_address, 'transfer_ownership', @function_args);
stop_prank(contract_address);

let function_args = ArrayTrait::new();
let returned = call(contract_address, 'get_owner', @function_args).unwrap();
let contract_owner = *returned.at(0_u32);

assert(contract_owner == new_owner, 'Ownership was not transferred');
}

#[test]
fn test_only_owner_can_transfer_ownership() {
let deployer_address = 123;
let contract_address = setup(deployer_address);

let hacker = 456;
let mut function_args = ArrayTrait::new();
function_args.append(hacker);

start_prank(hacker, contract_address);
match invoke(contract_address, 'transfer_ownership', @function_args) {
Result::Ok(x) => assert(false, 'Should not transfer ownership'),
Result::Err(x) => assert(x.first() == 'Caller is not the owner', 'Incorrect error message'),
};
stop_prank(contract_address);
}

Our test suite is complete, but there’s one more thing we should implement in our smart contract for the sake of completeness: to emit an event when ownership changes.

Emitting Events

In Cairo 1, an event is an empty function annotated with the event attribute. To emit an event you only need to call the function with the required arguments.

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

When searching for it with an indexer, the key of the event is the hash of the function’s name (OwnershipTransferred) and its values are the passed arguments when calling the function.

This completes our journey. You can find the source code of this tutorial here.

Conclusion

Protostar allows us to test success and failure scenarios using a workflow that resembles how you would declare, deploy and interact with a smart contract on mainnet but programmatically using cheatcodes.

As powerful as the test runner is, it has a drawback: it’s slow. Our test suite that consists of only three test cases takes 40 seconds to run. Imagine working on a project with dozens of tests as it’s normally the case, it might slow down development.

There are multiple reasons for this lack of performance compared to Cairo’s native test runner that is incredibly fast (but not as flexible). First, Protostar is still written in Python and not yet in Rust as the default test runner that comes with Cairo 1. Second, for each test Protostar creates a new mock instance of Starknet so your tests are properly isolated from each other. They run in an environment closer to the real one and the mock instance can be controlled at will using cheatcodes to recreate complex test scenarios.

The types of tests that we are creating in this tutorial are what’s called “black box” tests where you can only interact with your smart contract using its public interface, just like users and other smart contracts will once deployed to testnet or mainnet. This type of test gives you a more realistic feedback of how your smart contract actually behaves.

Software Mansion, the company behind the development of Protostar, is well aware of this performance problem and they are already working on a rewrite of the tool using Rust to execute tests faster.

Another current limitation of Protostar is the inability to test events. Ideally, you’d like to be able to assert if an event has been emitted with the expected keys and values. Luckily for us, Software Mansion is already working on adding the expect_event cheatcode to the test runner as you can see on their public roadmap.

Starknet tooling is improving even though Cairo itself is still evolving. Keep an eye on Protostar’s development on Github.

--

--

David Barreto
Starknet Edu

Starknet Developer Advocate. Find me on Twitter as @barretodavid