Skip to content
Bulletproof SolidityπŸ›‘οΈ

Bulletproof SolidityπŸ›‘οΈ

Structure your smart contracts better

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 the libraries 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, a RewardManager 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 as Vaults, 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, like aave and uniswap, 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.


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