Storage Variable Clashing in StarkNet
How storage clashing can occur with StarkNet smart contracts and best practices for prevention
🗄️ How is Storage Handled?
Contract storage on StarkNet is handled with simple key/value pairs. According to the StarkNet documentation:
Storage Layout
Contract storage is a persistent storage space where you can read, write, modify, and persist data. Storage is a map with 2²⁵¹ slots, where each slot is a felt and is initialized to 0.
Storage Basic Functions
The basic function for reading storage returns
value
stored inkey
–
let (value) = storage_read(key)
The basic function for writing to storage writes
value
tokey
–
storage_write(key, value)
Storage variables, decorated in contracts with @storage_var
, have a bit more complexity. The StarkNet compiler maps their name and values (in Cairo code) to an address generated by StarkNet’s own sn_keccak
method (as-is or through a hash chain for nested mappings). The important takeaway here, however, is that storage variables are simply treated as hashed key/value pairs.
Contract Extensibility in StarkNet
OpenZeppelin pioneered the Extensibility Pattern which consists of guidelines for contracts to safely import functionality and state from battle-tested libraries. The basic idea is for contracts to import and expose (with @external
and @view
decorators) the methods they want to use from libraries. For example, consider the popular ERC20 library. All of the methods and state management to deploy an ERC20 token already exist in the library. The user just needs to expose the requisite methods in a contract and voila! Your contract is ready to deploy.
The reason libraries do not expose their methods is because Cairo will automatically export them regardless if they’re imported or not. This can be dangerous.
The interesting question with this pattern is: if libraries set their own state with storage variables, what happens when a contract imports from multiple libraries that share the same name for those storage variables?
💥 Storage Clashing from Separate Libraries
Let’s look at these two libraries:
While the example methods share the same name, they belong to their respective namespace i.e. LIBRARY_A.increase_balance
and LIBRARY_B_increase_balance
. The storage variable balance
, however, is not encompassed by either namespace. This is important to remember.
Now, let’s look at a contract that will import from these libraries and expose their methods.
Notice that neither library’s storage is explicitly imported into the contract—just the namespace. Now, let’s test the exposed methods of contract_c and discover what happens to the libraries’s respective storage.
The result:
Wait, what happened? The StarkNet compiler did not differentiate between the two balance
storage variables even though they seemed to “privately” belong to their respective libraries. In other words, the compiler sees both balance
storage variables as references to the same variable.
The StarkNet compiler will fail, however, if the same-named storage variables differ in any way. Such differences include variable names, return value names, and the number of keys. Here’s a simple example to illustrate:
# library_a
...@storage_var
func balance() -> (res: felt):
end
...
-----------------------------------------# library_b
...@storage_var
func balance() -> (var: felt):
end
...
If we change the return variable name in library_b as above and try to compile contract_c, the compilation will fail. Any such difference will return this AssertionError:
f'Found two versions of auto-generated file "{input_file.filename}":\n'
AssertionError: Found two versions of auto-generated file "autogen/starknet/storage_var/balance/impl.cairo":
...
Main takeaway: if a contract imports from multiple libraries and these libraries happen to share a storage variable name (i.e. balance
), these variables will likely clash if the compiler doesn’t catch it.
🛡 ️Prevent Storage Variable Clashing
As of this writing, the best solution consists of prefixing storage variable names with the library’s name or namespace. For example: ERC20_balances
, ReentrancyGuard_start
, and Ownable_owner
.
For a proof of concept, let’s change the storage variables of the example libraries to LIBRARY_A_balance
and LIBRARY_B_balance
.
After running the exact same test, here is the result:
Conclusion
Given how new and cutting-edge the StarkNet network and Cairo programming language are, best practices will inevitably change as existing patterns and conventions evolve and new ones take their place. In the mean time, prefix your storage variables!
Special thanks to Martín Triay, Julissa Dantes, and OpenZeppelin for inspiring this research. Check out the original discussion here.
Interested in learning more about StarkNet and Cairo? Check out these excellent resources:
- StarkNet and Cairo official documentation
- StarkNet Shamans
- Getting started with OpenZeppelin Contracts for Cairo
- And for a more comprehensive list: Awesome StarkNet
Join Coinmonks Telegram Channel and Youtube Channel learn about crypto trading and investing