Bulletproof Solidityπ‘οΈ
Often, I see developers struggling to organize their smart contracts. While this may not seem like a big deal initially, it can lead to maintenance challenges in the future. As the complexity of a project grows, poorly organized code can slow down development. Proper planning in the early stages of a project can prevent a lot of technical debt, making the codebase clean, modular, and easy to navigate. It not only simplifies the development process but also facilitates collaboration among team members, as a well-structured codebase is easier for new contributors to understand and work with.
Inspired by Aave V2's repository, I am going to share a simple yet efficient framework for organizing smart contracts in the context of a DeFi protocol. With minor adjustments, this framework can also be adapted for organizing other types of projects as well.
Checkout the foundry template here.
Directory structure for Contracts
βββ interfaces // All the interfaces used in the project goes here
β βββ external // Group external interfaces in this dir
βββ mocks // Mock contracts for testing and simulations
βββ protocol // Core protocol specific contracts
Β Β βββ common // Common shared contracts like AccessControl, etc.,
Β Β βββ oracles // Price oracles.
Β Β βββ libraries // Utilities like Math.sol, Errors.sol, goes here.
βββ misc // Miscallaneous contracts like RewardManager, etc.,
Β Β βββ core // Core protcol contracts like Vaults, Pools, etc.,
Β Β βββ integrations // External integration logic should be seperated.
| βββ aave
β βββ uniswap
Β Β βββ tokenization // Protocol specific tokens like ERC20, ERC1155.
Interfaces
The interfaces
directory houses all the interfaces utilized throughout the codebase. I recommened using interfaces for almost all the core contracts since it helps define the required functions and params before hand.
- External Interfaces: Grouping external interfaces within the
external
subdirectory for a clear distinction, when integrating with other protocols.
Mocks
The mocks
directory is dedicated to mock contracts used primarily for testing and simulations. Mocks replicate the behavior of real contracts, allowing us to test various scenarios and interactions without the need for actual deployments.
Protocol
The protocol
directory hosts the core logic and components of the protocol. We can further divide it into subdirectories as follows:
-
Common: This subdirectory contains shared abstract contracts such as
AccessControl
, etc., that are supposed to be inherited by almost all the contracts across the protocol. -
Libraries: Utilities like
DataTypes
,Errors
,Math
, etc., should reside in thelibraries
subdirectory. These contracts provide essential functions and definitions that are reused across the protocol.
-
Miscellaneous (Misc): The
misc
subdirectory includes independant contracts that do not fit neatly into other categories. For instance, aRewardManager
contract might manage reward distribution mechanisms. We need not have a seperate directory for a such contracts. -
Core: Central to the protocol, the
core
subdirectory contains the main protocol contracts such asVaults
,Pools
, etc., These contracts implement the primary business logic of the protocol. -
Integrations: The
integrations
subdirectory is designated for contracts that manage external integration logic. This is applicable for composed protocols. By isolating external integration-related code, we can efficiently manage and update logic related to external protocols. Further grouping it based on the protocols within this directory, likeaave
anduniswap
, enhances maintainability as we can easily add or remove integrations. -
Tokenization: Finally, the
tokenization
subdirectory hosts protocol-specific ERC20/ERC721/ERC1155 tokens. For example, proof-of-deposit tokens, share tokens, position token, etc.,
By incorporating the above structure, the code becomes easily approachable. Due to seperation of concern, addition of new features will become hassle-free. Most importantly when the project grows, we can easily figure out what goes where. Also it makes the codebase very easy to test.
Shameless plug π: When using this framework, file import and paths could be difficult to manually type the path to import files. If you use VSCode IDE, you can leverage Solidity Inspector extension that helps you with auto-file import suggestions
Directory structure for Tests
Tests can be organized in the same way as above as well. We can group tests by its type then by feature.
βββ test/
βββ common/
βββ unit/
βββ fork/
βββ fuzz/
βββ invariant/
The common
directory should contain the harness contracts and other base test setup contracts that can be shared among other test contracts.
.
βββ common
βββ harnesses
β βββ VaultHarness.sol
β βββ PoolHarness.sol
βββ BaseTestSetup.sol
βββ BaseTestSetupLive.sol
Other directories can have test files grouped by feature.
βββ unit/
βββ vault/
β βββ trees/
β β βββ Deposit.tree
β β βββ Withdraw.tree
β βββ Deposit.t.sol
β βββ Withdraw.t.sol
βββ oracle/
...
You can see that we grouped tests based on the type then the feature. This helps to keep track of features and functions that are tested by just looking at the file names. If we try to add all the tests in the same file, say Vault.t.sol
, there are couple of issues:
- Test files will become too big.
- Difficult to keep track of the logic that has been tested/not-tested.
π‘ Note that we have a seperate directory for trees
. It is used to laydown the testcases in a tree format before implementing the actual tests and its called Branching Tree Technique (BTT)
. It was first introduced by PaulRBerg from Sablier and it soon became an industry standard.
@MorphoLabs experimenting Branching Tree Technique (BTT) suggested by @PaulRBerg π³
So far looks very promising. pic.twitter.com/9Gxw2K1akiβ Merlin Egalite | Morpho | Woke up Based (Hiring)π (@MerlinEgalite) August 5, 2023
As the tests are seperated based on the type, the test contracts should be named appropriately to enhance the testing pipeline. This can be done by just appending the type of the test to the test contract name.
For ex, Vault tests can be named as:
contract Vault_UnitTest is BaseTestSetup {}
contract Vault_ForkTest is BaseTestSetupLive {}
By doing so, we can selectively run the required tests during development. Also it would be easier to configure the CI test pipeline as well. If we wanna just run the unit tests, we can just run the command: forge t --mc UnitTest
. This will become handy when we want to run selective test suites.
CI Workflow config example:
...
- name: Run Unit tests
run: |
forge test --mt UnitTest -vvv
id: test
- name: Run Fork tests
run: |
forge test --mt ForkTest -vvv
id: test
...
Conclusion
In this piece, we looked into a framework to organize smart contracts in a structured way to avoid clutter. This will reduce the mental burden of remembering what goes where when adding or removing features. I've also added a Foundry template based on the above points to help you get started easily. Happy coding!
Acknowledgements
- This piece of article is inspired from "Bulletproof Node.js"