This won't be your typical article on basic gas-saving tips and tricks. Instead, it provides a high-level view, focusing on significantly reducing the gas required for deployment and usage.
Finding the most efficient method can be a challenge, it may even require restructuring some functions or even some contracts. But fear not...
“Everything that can be invented has been invented.” - Charles H. Du
Not vary accurate, but great rule of thumb. Most functions that you are going to use have already been optimized for minimal computational requirements. You just need to find the answers (or the solutions *wink*).
The function below is used in a DAO-type system to return the vote count of a user at a given time. By understanding the context of other write functions and how block.timestamp
works, we can deduce that the Checkpoint array is sorted. This is based on the fact that block.timestamp
only moves in one direction (towards the inevitable end of time).
With this knowledge, we can implement a binary search algorithm to find our checkpoint more efficiently. You can copy the code from someone else (like I did), or microdose some "substances" and write it yourself.
When we compare those two functions, we can find that the gas cost for the normal search is linear (more checkpoints -> more gas), whereas the binary search has an exponential decrease in gas cost (more checkpoints -> a bigger percentage of gas saved).
Transfers and how the system implements them can lead to excessive gas costs. This happens when you perform too many or unnecessary transfers.
Unnecessary transfers include internal system-to-system transfers, where you move tokens from the user to the periphery contract and then transfer them again to the main vault.
Implementing such mechanics isn't necessarily incorrect, but it's not optimal from an economic perspective.
With the above function we can save ourselves some gas if we simply sent the funds to the vault, instead of doing 2 transfers. Same can be said if the vault mints any tokens to the periphery contract and then the periphery distributes them.
When it comes to math in older Solidity versions, we were accustomed to seeing only OZ math being used. However, with the newer versions, we started implementing simple math operations (a * b / c) and creating our own functions.
This is beneficial, as it gives us the ability to perform simple subtraction without the fear of underflow. However, it has led developers to completely abandon the safe and economical math functions and dive straight into simple math.
While on this topic, I must mention that doing complex math using basic methods like direct multiplication and division can be dangerous, as it's error-prone.
Apart from the potential bugs that may arise, we are also wasting gas on calculations that could benefit from optimization or, even better, refactoring.
For example, consider these two functions, they achieve the same result, but the second one is approximately 18% cheaper than the first.
This topic might be somewhat controversial, but I believe that anything not essential in Solidity should be handled by some back-end process. While some protocols can shift a considerable amount of functionality to a back-end or integrate it within the UI, others might require everything to be on-chain.
Generally speaking, the more complex your protocol is, the more components can be moved off-chain. This approach is beneficial, as it saves the gas required to deploy and operate substantial portions of code.
Examples of functionalities that can be efficiently removed include complex find algorithms, array sorters, user imputed data (you will see what I mean in Storage hashes) and any other elements that can be securely removed from your contract.
If you are a developer, you will know what Merkle trees are (hopefully). However, the issue is, do you know when to use them? Well, this developer didn't know, which caused him to pay about 6.2K for a transaction that was worth 50 dollars.
Firstly, let me provide you with a quick (bad) description that will help us identify their use cases.
Merkle trees are compact (user) storage data, great at storing pieces of the same kind of information. Used when we need to insert external information into the EVM.
This could include, as in the aforementioned case, whitelisted tokens, or other data such as users and their reward amounts. It can even be something more complex, like users and the roles they have (if you have many different roles such as admins, guardians, owners, operators, and so on...).
It doesn't matter what you store there, as long as it's a repeatable patters you can get it off your head and into the code.
While discussing Merkle trees, I cannot go away without mentioning storage hashes.
They are incredibly useful when used correctly. However, improper use can lead to complications and can be quite dangerous. Proper use includes, but is not limited to:
Verifying the important variables on creation.
Including every variable in the hash.
Ensuring that once a hash is used, it is promptly deleted.
With these security considerations addressed, let me introduce you to an impressive piece of code:
The snippet above demonstrates how to hash an order and store it in a 32-byte space. Compared to using a giant struct, this hash implementation can save a significant amount of gas.
Furthermore, this approach eliminates the need to store and later retrieve these structs when processing orders. It also removes the need for checks during order processing, as any incorrect user input will result in keccak256 computing an unknown value that won't be stored.
This also involves off-chain elements in a sense, as users are not expected to remember and retain all of this information. Here, technically, the developers have "moved" their storage off-chain (not entirely, but to a significant extent).
I'm saving the best for last. If you're reading this, your attention span is longer than I expected, congrats!
Simplicity is crucial when coding in any language, especially when deploying on the EVM where hacks are everywhere, and the gas cost is higher than Snoop Dogg.
That's why it's crucial to keep everything simple. To achieve this, consider refactoring your functions, making them smaller, and even splitting them if necessary. Your sole goal should be to make everything simpler and clearer to understand, commonly known as “writing clean code”.
It usually takes a few tries, as no developer can code the perfect function on the first attempt. This is me giving you permission to play with your functions. Don't be scared, it's part of the process of real optimization. Slowly but surely, bit by bit, you'll develop better functions and consequently, better contracts.
In the meantime, ensure you're making the function cheaper, not the opposite (and more secure, but that’s for another article *wink*). You can achieve this with foundry gas reports.
An added benefit tis that the code-base will have fewer bugs, and its audit tends to be on the cheaper side.
That's all from me. If you've enjoyed this article, feel free to share it with the people who will need it.