Hello! Today I’d like to propose a development pattern to address contract extensibility.
The problem
Smart contract development is a critical task. As all software development, 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 not an easy 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
There’s no function overloading making function selector collisions very likely – more so considering selectors do not take function arguments into account
Any @external function defined in an imported module will be automatically re-exposed by the importer (i.e. the smart contract)
Builtins cannot be imported more than once in the entire imports hierarchy, resulting in errors on import (or errors on compilation if not added) – and most contracts will need the same common set of builtins such as pedersen, range_check, etc.
To overcome all of these problems, I propose the following guidelines.
The pattern ™️
The idea is to have two types of contracts: base / library contracts and frontend / extended contracts. The flow is easy: base contracts implement reusable logic which will be reused/extended by frontend contracts. Frontend contracts can be deployed, base contracts not.
Base / library contracts
All function and storage variable names must be prefixed with the contract name to prevent clashing with other base contracts
Must not implement any external functions (except maybe for some basic @view ones)
Must not declare any builtin
Must not implement constructors
Must not call initializers
Should implement initializers if needed (as any other base contract function, never as @external)
Should document required builtins for frontend contracts to satisfy
Extended / frontend contracts
Should import from base contracts
Should implement external functions if needed
Must declare any builtins required by their base contracts
Should implement a constructor that calls initializers
Must not call initializers on any function aside the constructor
Note that since initializers will never be marked as @external and they won’t be called from anywhere but the frontend contract constructor, there’s no risk of double-initialization. It’s up to the base contract developers not to make base contract initializer’s inter-dependent to avoid weird dependency paths.
So this is the pattern! There’s a working demonstration you can check out in this merged PR that implements ERC20_Pausable based on the ERC20, Ownable, and Pausable base contracts.
It would be awesome if Cairo itself could take care of the builtins issue, so there’s no need to be careful with it, nor be mindful of the requirements of each base contract when extending them. I think there’s no good reason for that information to leak to the frontend contracts.
I think this extensibility pattern is fine in the short term but to me it creates more complexity for us as contract devs (particularly in testing, composability, and readability).
I believe this could be abstracted away from the contract dev using composition under the hood. Which would allow for clean dependency injection too. Similarly as we talked about solving the Uint problem under the hood as well.
These are fair points, but I have a few questions here:
Why do you feel there’s no composability? you can call other deployed contracts and import code.
What’s a scenario you think it too complicated?
where would you have a (common) function selector issue?
Doing:
from a import foo
and then
func foo() ...
in the same file shouldn’t be possible.
The point about external is true, and I think it was intended to work like you suggest with the base contracts - imported files should not have external functions.
How would you suggest solving the built-ins issue? The compiler resolving all the required built-ins when compiling the “main” contract?
I have add to copy / past code from another file; one will have to check that I did not introduce bug/malicious code before deploying.
If I import ERC20_base, there is a bug while compiling because of the “builtins already imported” error. This sucks, I don’t want bugs when I compile code that works as expected
As a side note, when I write Solidity smart contracts and a contract inherit from another contracts, it inherits its external functions too. Why is that a bad thing?
Right, although it wasn’t clear to me until I started exploring different patterns. Let me state that although this pattern makes sense, it was not obvious at all and I went through a few prototypes until I reached this. Also, “state management” i.e. where are storage variables defined wasn’t obvious either.
Exactly, this should be managed by the compiler not the users. I’m not used to this problem in other programming languages.
wrt state management: are you suggesting something different?
wrt built-ins: I think implementing a distinct union (a set union) in the compiler isn’t that hard.
But we do have to consider what it means in terms of the development experience.
For example, if we consider versions of the built-ins (or versions of the library in general).
It also assumes that all relevant contracts are compiled together (a static linking), which in general seems ok to me.
Nope. I’m just saying that maybe you assumed the language should be used in a certain way which is not necessarily known by users (e.g. builtins only in frontend contracts). Letting users find out the hard way might be frustrating, and some users might never figure it out.
This is the core to me. Users expect the compiler to be able to deal with imports.
Today it has been brought to my attention that this exception could cause function name clashes, so we should not allow any kind of @external nor @view functions on base contracts.
If I were asked to vote on the direction Cairo should take to answer the question of code reuse, I would strongly vote to apply wisdom from the development of object oriented paradigm, which is composition over inheritance. Inheritance can be so easily abused that it should be considered an antipattern.
In my opinion current code inclussion mechanism should be refined rather in the direction of traits than a full blown inheritance.
@maciejka I’m familiar with traits as they appear in Rust, which seems very different from the paper that you shared. I wanted to check if you actually meant traits as defined by that paper, which are a mechanism of composition for classes so it requires a prior discussion about adding classes to Cairo, or if you meant traits as in Rust.
I’d support traits as in Rust but I think there is a lot more that needs to be elaborated to see what that would look like in Cairo. That paper requires classes, but even Rust traits would require some “unit of behavior” such as structs, and neither of these are present in Cairo today, the modular unit of behavior is the file.
In contrast, in Solidity the main unit of behavior is the contract that can be combined with other contracts through multiple inheritance. I agree that multiple inheritance in Cairo should be avoided as much as possible.
So for me an initial discussion is: should Cairo move away from the file as the modular unit of behavior and towards something that is explicitly defined like structs with traits or like Solidity contracts (sans inheritance)?
My rough idea is that traits behave the way described in the paper and are just a unit of code reuse. Traits can be combined into other traits and contracts. Traits are abstract = don’t have storage and can’t be deployed, contracts are final, can’t be combined, can be deployed.
Allowing multiple inheritance makes the rules about function overloads and virtual dispatch decidedly more tricky, as well as the language implementation around object layouts. These impact language designers/implementors quite a bit, and raise the already high bar to get a language done, stable and adopted.
It is simple to think this way, if class A inherits from multiple classes, then the class A will have the same grandparent class multiple times, this means the code will be complicated and a series of bugs will go unacknowledged. Personally, I think multiple inheritance has a bad rap, and that a well done system of trait style composition would be really powerful/useful… but there are a lot of ways that it can be implemented badly, and a lot of reasons it’s not a good idea in a language like C++.