This is a simple style-guide we are using at Cygnus to re-write our core contracts in Cairo. But first, what is Cairo?
Cairo is a programming language for writing provable programs, where one party can prove to another that a certain computation was executed correctly. Cairo and similar proof systems can be used to provide scalability to blockchains.
StarkNet uses the Cairo programming language both for its infrastructure and for writing StarkNet contracts.
Since our core contracts are based on Solidity and rely on the language's unique features, we have had to adopt a different mindset to transcribe the contracts. We encountered a few differences between the two, thus the purpose of this document.
Our Solidity contracts were written with a very specific layout:
pragma solidity 0.8.4:
- Libraries
- Storage
- Constructor
- Modifiers
- Constant functions
- Non-Constant functions
Our Solidity core contracts layout |
Every single contract on our core (can be found here: https://github.com/CygnusDAO/cygnusdao-core) follows this practice. Starts with the pragma solidity >= 0.8.4
and follows with the contract layout mentioned above. The contract instance from our core inherits all the structs/errors/events/functions from the interface, and they commit to implement all the functions found on there.
In Cairo, there are interfaces but there is no inheritance. This means that we can't just rely on the interface itself to provide us with the basic structure of our contract. That's the first big difference.
The second difference is that Cairo only supports 1 constructor per compiled contract (ie. if a contract has multiple child contracts then there must only be 1 constructor defined across all).
Our contracts rely on inheritance, and follow this path:
The third and most important difference is data types and execution types:
There is only 1 data type: felt. A felt stands for an unsigned integer with up to 76 decimals. It is used to store addresses, strings, integers, etc. We declare it with:
There is only 1 execution type: func. All storage variables (except structs) such as mappings, events, constructors and functions are declared with the same func keyword:
Since declaring everything wtih a func
keyword can get confusing, Cairo provides decorators which are used on top of the function declarations to distinguish each. These all start with @
:
We tried to follow our Solidity pattern of:
Storage -> Constructor -> Modifiers -> Constant Functions -> Non-Constant Functions
And this fits perfect with Cairo. We bring the structs and events into the contract itself (instead of the interfaces like with Solidity) and with the decorators we separate the contracts into the following:
%lang starknet
- Structs
- Events
- Storage
- Constructor
- Storage Getters
- Constant functions
- Non-Constant functions
Our Cairo core contracts layout |
The layout is similar to our Solidity contracts, but we define the structs and events in the contract themselves, and add Storage Getters
between constructors and constant functions, and that's pretty much it. Instead of pragma solidity, we first start the contract with %lang starknet
and follow the pattern:
Structs -> Events -> Storage -> Constructor -> Storage Getters -> Constant functions -> Non-Constant Functions
The storage getters are necessary if you want to make them public. In solidity, the compiler creates getters for all state variables declared as public, in Cairo all @storage_vars are private. Thus if we want to make them public we must make a getter function ourselves.
Solidity follows the camelCase approach for every function and state variable, whether it is a uint256 or a string or an address.
We then use PascalCase for structs, contracts and events which is the standard in Solidity but never camel case.
Cairo follows the Python or Ruby approach of camel_case in and we followed this approach. The style we used is very similar to OnlyDust's but with a few changes (https://github.com/onlydustxyz/development-guidelines/blob/main/starknet/README.md).
The main change is that we use Mixed_Case_Underscore for storage vars. Since we can't declare an event as event {EventName}
or a mapping as mapping
, we have to differentiate between all the funcs. Storage vars are the only ones which follow this approach along with library names, but the rest follow OnlyDust's conventions. We are still not sure if we will remain with this approach. Also, We customized the error message to see exactly where the error is coming from for debugging purposes, but that's pretty much it.
Our Cairo naming style |
Single storage example:
Mapping storage example with a struct:
Using simple returns can help us simplify code readability and write the same in less lines of code.
Simple returns in Cairo are only allowed for internal functions (externals only support tuples at the moment), but this is perfect when using custom-libraries or Cairo global variables such as get_caller_address()
or get_contract_address()
Example:
Notice the simple felt
return on the first line.
This then allows us to use the address of the sender like in Solidity:
Without simple returns, in Cairo we would have to call each variable and then assign it like so:
context: https://github.com/CygnusDAO/starknet-factory/blob/main/src/cygnus_core/utils/context.cairo
Writing own math libraries is fun, but can be deadly to many protocols. Unlike Solidity >= 0.8, Cairo calculations are still subject to overflow/underflow. As such, it is still needed to do sufficient checks on each calculation.
Our contracts make use of popular Starknet libraries that have been tested and considered safe for use.
The main math libraries we have used:
OpenZeppelin (covers 99% of cases): https://github.com/OpenZeppelin/cairo-contracts/blob/main/src/openzeppelin/security/safemath/library.cairo
Math64x61 (for more complex operations): https://github.com/influenceth/cairo-math-64x61/blob/master/contracts/cairo_math_64x61/math64x61.cairo
In saying this, we have developed our own felt-based math library to use for our Oracle but it is NOT safe to use. It has only been tested in alpha-goerli and is NOT safe for use in production. We will most likely remove the library once we deploy on mainnet.
Cairo has its own version of solidity's CREATE2
. Taken from the release notes:
The address is a Pedersen hash on the caller address, a salt (random or chosen by the deployer), the contract code hash, and the hash of the constructor arguments, all appended by a prefix.
This is a very important part of our contracts, since in 1 transaction we deploy 2 contracts (borrowable + collateral), and each must have each other's addresses in storage. To avoid having to deploy the contracts and then using a setter method on the contracts to store each other's addresses, we developed our own method:
While developing our contracts we could not find a real example out in the wild. Our factory contract makes use of this technique, so here is the full code we used:
Factory contract:
Pre-compute contract library:
At the time of writing the contracts we could not find contracts that implemented this method, however this was a few months ago. Maybe now someone has figured out a more concise way?
For reference check out our contract library, factory contract and deployer contracts to see how this works:
create2_address_lib: https://github.com/CygnusDAO/starknet-factory/blob/main/src/cygnus_core/libraries/cygnus_pool_address.cairo
factory: https://github.com/CygnusDAO/starknet-factory/blob/main/src/cygnus_core/giza_power_plant.cairo#L736
borrowable_deployer: https://github.com/CygnusDAO/starknet-factory/blob/main/src/cygnus_core/albireo_orbiter.cairo#L121
collateral_deployer: https://github.com/CygnusDAO/starknet-factory/blob/main/src/cygnus_core/deneb_orbiter.cairo#L115
Reentrancy attacks have drained so many protocols that is hard to keep count (actually pcaversaccio is keeping count on github, check it out! https://github.com/pcaversaccio/reentrancy-attacks)
Our contracts rely heavily on reentrancy guards, as do (should) most lending protocols. Since Cairo has no modifiers, we make use of OZ' neat reentrancy guard solution for our borrow, repay, leverage and deleverage functions.
Example:
OZ' Reentrancy Guard: https://github.com/OpenZeppelin/cairo-contracts/blob/main/src/openzeppelin/security/reentrancyguard/library.cairo
Using events is the standard in EVMs for any serious protocol, specially when developing frontend libraries. Most blockchains don't provide an API to access contract's storage like a database. If we wish to do more complex queries or get historical data, then this becomes an issue.
Indexers are tools that listen for the events emitted by the smart contracts and use them to update their view over the application's state. For Starknet we have been using Apibara, and we definitely recommend giving them a try.
To use events, the process is similar to Solidity. First, we define the event in the contract:
Notice the empty body. Like storage_vars, the body must be left unimplemented. We can then emit the event in the function when a certain condition takes place (for example a successful borrow
):
Solidity >= 0.8 allows to define custom errors, which in our opinion are a game changer. It reduces bytecode significantly by removing reason strings and allows to pass variables to see exactly why the transaction reverted.
Well Cairo allows for the same thing! We can pass variables to our errors to see why the tx reverts. The caveat in this is that only variables which do not rely on AP are allowed to be passed to these errors.
To define a custom error and revert a transaction when a condition is met, we simply pass an error_message
attribute with a string and the parameters inside curly brackets {}. This will display the error in popular wallets such as ArgentX or Braavos with the variables passed:
Notice that this is allowed as assets
is passed as an argument and is not dependent on AP.
Since our contracts depend on asset prices to decide how much a user may borrow, we rely on our oracle to correctly price a liquidity position. Our first version of our oracle was a TWAP oracle, however as reliable as they can be there's still some limitations that comes with their use. In our case the biggest caveat was the delayed price of an asset can cause problems for lenders and borrowers at a time of high market volatility.
Our latest oracle uses external price feeds instead of TWAPs to price liquidity positions. When migrating to Starknet we ended up using Empiric's price feeds, and integrating with them was really a breeze. To integrate with them, we have to only define 3 variables in our contracts and boom:
Then to get for example the price of an asset we just call their contract:
And that's it! Integrating with them was surprisingly simple. We suggest taking a look into their product, it's quite interesting and unique how they work. From their website:
Most existing oracle solutions operate as trusted, off-chain black boxes. Empiric's smart contracts leverage zk computation to aggregate raw signed data with total transparency and robustness. Because the oracle is entirely on-chain, anyone can verify any data point from origin through transformation to destination.
Check them out here: https://docs.empiric.network/
Our oracle for reference: https://github.com/CygnusDAO/starknet-price-oracle/blob/main/src/cygnus_oracle/cygnus_nebula_oracle.cairo
We will keep updating as we update contracts (if needed) or find new techniques worth sharing.
Thanks for reading!