Expect this pattern to evolve (as it has already done) or even disappear if proper extensibility features are implemented into Cairo.
Smart contract development is a critical task. As with all software development, it is error prone; but unlike most scenarios, a bug can result in major losses for organizations as well as individuals. Therefore writing complex smart contracts is a delicate task.
One of the best approaches to minimize introducing bugs is to reuse existing, battle-tested code, a.k.a. using libraries. But code reutilization in StarkNet’s smart contracts is not easy:
- Cairo has no explicit smart contract extension mechanisms such as inheritance or composability
- Using imports for modularity can result in clashes (more so given that arguments are not part of the selector), and lack of overrides or aliasing leaves no way to resolve them
- Any
@external
function defined in an imported module will be automatically re-exposed by the importer (i.e. the smart contract)
To overcome these problems, this project builds on the following guidelines™.
The idea is to have two types of Cairo modules: libraries and contracts. Libraries define reusable logic and storage variables which can then be extended and exposed by contracts. Contracts can be deployed, libraries cannot.
To minimize risk, boilerplate, and avoid function naming clashes, we follow these rules:
Considering the following types of functions:
private
: private to a library, not meant to be used outside the module or importedpublic
: part of the public API of a libraryinternal
: subset ofpublic
that is either discouraged or potentially unsafe (e.g._transfer
on ERC20)external
: subset ofpublic
that is ready to be exported as-is by contracts (e.g.transfer
on ERC20)storage
: storage variable functions
Then:
- Must implement
public
andexternal
functions under a namespace - Must implement
private
functions outside the namespace to avoid exposing them - Must prefix
internal
functions with an underscore (e.g.ERC20._mint
) - Must not prefix
external
functions with an underscore (e.g.ERC20.transfer
) - Must prefix
storage
functions with the name of the namespace to prevent clashing with other libraries (e.g.ERC20_balances
) - Must not implement any
@external
,@view
, or@constructor
functions - Can implement initializers (never as
@constructor
or@external
) - Must not call initializers on any function
- Can import from libraries
- Should implement
@external
functions if needed - Should implement a
@constructor
function that calls initializers - Must not call initializers in any function beside the constructor
Note that since initializers will never be marked as @external
and they won’t be called from anywhere but the constructor, there’s no risk of re-initialization after deployment. It’s up to the library developers not to make initializers interdependent to avoid weird dependency paths that may lead to double construction of libraries.
Presets are pre-written contracts that extend from our library of contracts. They can be deployed as-is or used as templates for customization.
Some presets are:
- Account
- ERC165
- ERC20Mintable
- ERC20Pausable
- ERC20Upgradeable
- ERC20
- ERC721MintableBurnable
- ERC721MintablePausable
- ERC721EnumerableMintableBurnable
- Following Cairo's programming style, we use
snake_case
for library APIs (e.g.ERC20.transfer_from
,ERC721.safe_transfer_from
). - But for standard EVM ecosystem compatibility, we implement external functions in contracts using
camelCase
(e.g.transferFrom
in a ERC20 contract). - Guard functions such as the so-called "only owner" are prefixed with
assert_
(e.g.Ownable.assert_only_owner
).
Unlike the Solidity version of OpenZeppelin Contracts, this library does not implement hooks. The main reason being that Cairo does not support overriding functions.
This is what a hook looks like in Solidity:
abstract contract ERC20Pausable is ERC20, Pausable {
function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual override {
super._beforeTokenTransfer(from, to, amount);
require(!paused(), "ERC20Pausable: token transfer while paused");
}
}
Instead, the extensibility pattern allows us to simply extend the library implementation of a function (namely transfer
) by adding lines before or after calling it. This way, we can get away with:
@external
func transfer{
syscall_ptr : felt*,
pedersen_ptr : HashBuiltin*,
range_check_ptr
}(recipient: felt, amount: Uint256) -> (success: felt):
Pausable.assert_not_paused()
ERC20.transfer(recipient, amount)
return (TRUE)
end