Gas optimisation is not only for smart contract developers!
That statement can seem strange, but I’ll demonstrate that it actually makes sense (if you are a DeFi enjoyer who wants to save a few cents/bucks).
Initial statement
From earlier gas optimization tests, I noticed that creating a new storage slot on the blockchain is consuming a lot of gas, much more than updating the value at an existing spot. Given that, what can DeFi users do to avoid creating new storage slots? They can avoid to erase existing storage spaces that they intend to reuse in the future.
Demonstration
Let’s say that you hold a balance of 1 USDC (= 1000000 with 6 decimal places). This balance is stored in the USDC contract.
Note: The table below is a simplified visual representation. Blockchain data is not stored like that.
+---------+---------+
| User | Balance |
+---------+---------+
| 0xa11ce | 1000000 |
| 0xb0b | 5000000 |
| ... | ... |
+---------+---------+
If you transfer all your balance, your balance becomes 0 and your storage slot is erased totally. If, later, you get USDC again, the new transaction will need to recreate a storage slot to store the balance.
In the other hand, if we let 1 wei (= 0.000001 USDC) in the wallet, we would only need to update this value.
To compare the gas usage, we will use a mock ERC20 contract and first have our user Alice (0xa11ce) transfer all of her balance while the second case will let 1 wei in her wallet. Then, we will recover the sent ERC20 to compare the gas usage between a new balance and an updated balance.
Operations will be:
- Alice deposits mockERC20 into a vault contract
- Alice withdraws the deposited mockERC20 back to her wallet
The following tables represent the balance changes over time.
First scenario:
+---------+-----------------+-----------------------+---------------+
| Address | Initial balance | Balance after deposit | Final balance |
+---------+-----------------+-----------------------+---------------+
| 0xallce | 1000000 | 0 | 1000000 |
| vault | 0 | 1000000 | 0 |
+---------+-----------------+-----------------------+---------------+
Second scenario:
+---------+-----------------+-----------------------+---------------+
| Address | Initial balance | Balance after deposit | Final balance |
+---------+-----------------+-----------------------+---------------+
| 0xallce | 1000000 | 1 | 1000000 |
| vault | 0 | 999999 | 0 |
+---------+-----------------+-----------------------+---------------+
I created 2 identical “vault” contracts with deposit and withdraw functions. Why 2? Because in the gas report, we will see the gas usage split by contract. With only 1 contract, we would not be able to analyse the output.
Vault1.sol / Vault2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract Vault1 {
function deposit(IERC20 token, uint256 amount) public {
token.transferFrom(msg.sender, address(this), amount);
}
function withdraw(IERC20 token, uint amount) public {
token.transfer(msg.sender, amount);
}
}
Gas.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "../../lib/forge-std/src/Test.sol";
import { MockERC20 } from "../../mocks/MockERC20.sol";
import { Vault1 } from "./Vault1.sol";
import { Vault2 } from "./Vault2.sol";
contract GasTest_2_1 is Test {
MockERC20 public mockERC20;
Vault1 public vault1;
Vault2 public vault2;
address alice = vm.addr(0xa11ce);
uint256 balance = 1 ether;
function setUp() public {
mockERC20 = new MockERC20();
vault1 = new Vault1();
vault2 = new Vault2();
mockERC20.mint(alice, balance);
mockERC20.mint(address(vault1), balance);
mockERC20.mint(address(vault2), balance);
vm.startPrank(alice);
mockERC20.approve(address(vault1), balance);
mockERC20.approve(address(vault2), balance);
vm.stopPrank();
}
function testVault1() public {
vm.prank(alice);
vault1.deposit(mockERC20, balance);
assertEq(mockERC20.balanceOf(alice), 0);
vm.prank(alice);
vault1.withdraw(mockERC20, balance);
assertEq(mockERC20.balanceOf(alice), balance);
}
function testVault2() public {
vm.prank(alice);
vault2.deposit(mockERC20, balance - 1);
assertEq(mockERC20.balanceOf(alice), 1);
vm.prank(alice);
vault2.withdraw(mockERC20, balance - 1);
assertEq(mockERC20.balanceOf(alice), balance);
}
}
Running the following command, we obtain the results:
forge test --gas-report --match-contract GasTest_2_1
| test/2.1_User1Wei/Vault1.sol:Vault1 contract | | | | | |
|----------------------------------------------|-----------------|-------|--------|-------|---------|
| Function Name | min | avg | median | max | # calls |
| deposit | 34728 | 34728 | 34728 | 34728 | 1 |
| withdraw | 54851 | 54851 | 54851 | 54851 | 1 |
| test/2.1_User1Wei/Vault2.sol:Vault2 contract | | | | | |
|----------------------------------------------|-----------------|-------|--------|-------|---------|
| Function Name | min | avg | median | max | # calls |
| deposit | 43433 | 43433 | 43433 | 43433 | 1 |
| withdraw | 37775 | 37775 | 37775 | 37775 | 1 |
Conclusion
From these results, we can see that obtaining a 0 balance (when depositing all of the wallet balance into the vault) is costing less (34728) than letting 1 wei in the wallet (43433). That seems coherent to get a discount for deleting data from the blockchain. But then, the difference is much more important when having to recreate the balance (54851) instead of updating it (37775).
The total gas difference is 8371.
Let’s take a gas price of 10 Gwei on Mainnet and an ETH price of $2,500 (yes, it just pumped after months of chop, so I let you re-do the calculations).
That will give us a total transaction cost reduction of: 8371 x 0.00000001 x 2500 = 0.00008371 x 2500 = $0.21.
If we decompose, letting 1 wei will cost initially 8705 gas more, or $0.22 more, but then updating the balance will cost 17076 less gas, or $0.43 less.
What is true for token balances is true for other kind of stored data, with the exact same gas savings. Approval amounts, for instance, are stored in a similar way as balances and you could decide to approve 1 wei more than needed to keep the slot open for the next transfer.
Limitations
- This trick works on Mainnet and can save anywhere from a few cents to a few dollars depending on network congestion, but is significantly less interesting to use on L2s with 100 times cheaper transactions.
- If you know that you won’t be the one paying the next ERC20 transaction (you’ll receive your ERC20 from a CEX for instance), you’ll be better off with letting a 0 balance.
- If the gas is especially high when you transfer your balance, it may not be worth it to let 1 wei as it will increase the cost of your first transaction significantly.
This was written in the perspective of a user managing its wallet, but users can also time their interactions and benefit from the same principle. Let’s say that the gas price is quite high and you want to deposit some tokens in a newly created vault. You may want to let another user create the balance storage slot of the empty vault before you hop in!
In the repo, you’ll find a second implementation from the perspective of the contract keeping 1 wei when transfering its internal balance and the results are similar.
While our favorite tokens are bullish, the ocean is still flatish, so expect to hear from me soon!