Building a Gas-Optimized ERC-4626 Vault from Scratch
BADJO Dibéa Koffi
Published on May 16, 2026
Why Gas Optimization Matters in DeFi
Every DeFi protocol lives or dies by gas costs. Users compare vaults not just by APY, but by how much they pay to deposit and withdraw. A vault that costs 200k gas to deposit will lose users to one that costs 120k — even if the yield is identical.
ERC-4626 is the tokenized vault standard. It defines a common interface for yield-bearing tokens, making them composable across the ecosystem. But the reference implementation is deliberately simple — it prioritizes readability over efficiency.
In production, you need both.
The Reference Implementation Problem
OpenZeppelin's ERC-4626 implementation uses about 180k gas for a deposit. That's fine for a tutorial, but expensive on mainnet where gas regularly hits 50+ gwei.
The main costs come from:
- Multiple SSTORE operations (20k gas each for new slots)
- Redundant balance checks
- Unoptimized math operations
- Extra function calls for share price calculations
Storage Packing: The First Win
Solidity stores each variable in a 32-byte slot. If you have a uint256 and a bool, that's two slots — even though the bool only needs 1 bit.
The trick is packing related variables into a single slot:
// Before: 3 storage slots (60k gas for writes)
uint256 public totalAssets;
uint128 public lastHarvestTime;
bool public paused;
// After: 1 storage slot (20k gas for writes)
struct VaultState {
uint128 totalAssets;
uint64 lastHarvestTime;
bool paused;
}
VaultState public state;This single change saved ~40k gas per deposit because we write totalAssets and lastHarvestTime together.
Unchecked Math Where Safe
Solidity 0.8+ adds overflow checks to every arithmetic operation. Each check costs ~20-40 gas. In a vault, many operations are provably safe:
function _convertToShares(uint256 assets) internal view returns (uint256) {
uint256 supply = totalSupply();
unchecked {
return supply == 0 ? assets : assets * supply / state.totalAssets;
}
}We applied unchecked blocks to 8 internal math operations, saving approximately 200-300 gas per transaction.
Assembly for Critical Paths
For the deposit and withdraw paths, we dropped to Yul assembly:
function _efficientDeposit(uint256 assets) internal returns (uint256 shares) {
assembly {
let supply := sload(TOTAL_SUPPLY_SLOT)
let totalAss := shr(128, sload(STATE_SLOT))
switch supply
case 0 { shares := assets }
default {
shares := div(mul(assets, supply), totalAss)
}
sstore(TOTAL_SUPPLY_SLOT, add(supply, shares))
}
}The deposit path went from 180k to 108k gas — a 40% reduction.
Results
| Operation | Before | After | Savings |
|---|---|---|---|
| Deposit | 180k | 108k | -40% |
| Withdraw | 165k | 102k | -38% |
| Harvest | 95k | 71k | -25% |
These numbers are from our production vault on Arbitrum, measured across 10,000+ transactions.
Key Takeaways
- Storage packing is the highest-ROI optimization — start there
- Use
uncheckedonly where you can mathematically prove safety - Assembly is a last resort for critical paths, not a default
- Always benchmark with real mainnet gas prices, not just unit counts
- The best optimization is often architectural — fewer storage writes beats cheaper writes
Comments
Stay Updated
Get my latest articles delivered straight to your inbox.