Aave is a cornerstone of decentralized finance (DeFi), boasting the largest total value locked (TVL) of over $20 billion and generating more than $800,000 in daily fees. Its codebase is heavily audited, highly optimized, and has stood the test of time.
Recognizing Aave’s importance to the ecosystem, Cyfrin recently conducted a “public good” gas optimization audit, unsolicited by Aave, on commit 464a0ea of Aave V3.3. Given that the protocol already uses many advanced gas-saving techniques, this presented a fun and formidable challenge. Still, by combining several optimization strategies, we reduced gas usage by a total of 59,732 units in key areas like liquidations and core pool operations.
This article highlights some of the techniques we used so that other developers can apply similar strategies in their work. If you’re looking for professional help with gas optimization, reach out! We’d love to assist.
Note: In all code examples, the lines starting with "-" were removed, and the lines starting with "+" were added to achieve the optimizations.
Aave already uses cheatcode-based gas snapshots to measure the gas costs of specific code segments during unit tests, particularly for core protocol functions. Where snapshots were missing, we added our own [1, 2, 3, 4] to establish consistent baseline measurements.
With baselines in place, we began systematically analyzing the code for optimization opportunities. For each optimization, we followed the same process:
Each night, we ran Aave’s invariant fuzz testing suite to validate that none of the optimizations violated core protocol invariants, checks that go beyond the standard test suite.
For every optimization, we emphasized two core principles:
Our goal with every optimization was simple: preserve behavior, reduce gas.
Our audit surfaced 26 distinct gas optimization opportunities across Aave V3.3, employing a range of proven techniques.
Reading from storage is one of the costliest operations in the Ethereum Virtual Machine (EVM). Avoiding repeated reads of the same value can lead to meaningful savings. Aave already implements this pattern well, but we identified several cases [1, 2, 3, 4, 5, 6, 7, 8, 9] where caching could be applied more consistently.
A simple example appears in the RewardsDistributor
contract. During an event emission, the same storage slot was accessed twice unnecessarily:
+ uint32 distributionEnd = rewardConfig.distributionEnd;
emit AssetConfigUpdated(
asset,
rewards[i],
oldEmissionPerSecond,
newEmissionsPerSecond[i],
- rewardConfig.distributionEnd,
- rewardConfig.distributionEnd,
+ distributionEnd,
+ distributionEnd,
newIndex
);
By caching storageValue
in memory, we eliminate a redundant read without changing behavior.
Heuristic: Is a storage slot read multiple times within the same function, even though its value never changes? Do child functions or modifiers read the same, identical, unchanging storage slots as the parent function?
This technique is as simple as it sounds. When possible, using named return variables can save gas by removing the need for local variable declarations, especially when working with memory return types. We identified several cases [1, 2, 3, 4, 5, 6, 7, 8] where named returns offered measurable savings.
Two clear examples [1, 2] appear in ReserveLogic
:
function cumulateToLiquidityIndex(
DataTypes.ReserveData storage reserve,
uint256 totalLiquidity,
uint256 amount
- ) internal returns (uint256) {
+ ) internal returns (uint256 result) {
//next liquidity index is calculated this way: `((amount / totalLiquidity) + 1) * liquidityIndex`
//division `amount / totalLiquidity` done in ray for precision
- uint256 result = (amount.wadToRay().rayDiv(totalLiquidity.wadToRay()) + WadRayMath.RAY).rayMul(
+ result = (amount.wadToRay().rayDiv(totalLiquidity.wadToRay()) + WadRayMath.RAY).rayMul(
reserve.liquidityIndex
);
reserve.liquidityIndex = result.toUint128();
- return result;
}
function cache(
DataTypes.ReserveData storage reserve
- ) internal view returns (DataTypes.ReserveCache memory) {
- DataTypes.ReserveCache memory reserveCache;
+ ) internal view returns (DataTypes.ReserveCache memory reserveCache) {
reserveCache.reserveConfiguration = reserve.configuration;
reserveCache.reserveFactor = reserveCache.reserveConfiguration.getReserveFactor();
reserveCache.currLiquidityIndex = reserveCache.nextLiquidityIndex = reserve.liquidityIndex;
@@ -308,7 +305,5 @@ library ReserveLogic {
reserveCache.currScaledVariableDebt = reserveCache.nextScaledVariableDebt = IVariableDebtToken(
reserveCache.variableDebtTokenAddress
).scaledTotalSupply();
- return reserveCache;
}
Heuristic: Can a local variable be removed using a named return instead? Are any memory return variables missing named returns that would eliminate explicit return
statements and declarations?
In Solidity, structs stored in memory are passed by reference. This means changes made to them inside child functions (including view
and pure
) persist in the calling function’s context.
Aave defines UserConfigurationMap
as a struct containing a single uint256
bitmap:
struct UserConfigurationMap {
/**
* @dev Bitmap of the users collaterals and borrows. It is divided in pairs of bits, one pair per asset.
* The first bit indicates if an asset is used as collateral by the user, the second whether an
* asset is borrowed by the user.
*/
uint256 data;
}
In critical pool operations like liquidation, the storage reference to a user’s configuration map is passed into multiple child functions and may be modified several times. This results in the expensive pattern of repeated storage reads and writes. Ideally, each transaction should read from and write to any storage slot only once.
We implemented that pattern [1, 2, 3, 4, 5] by:
A clear example comes from SupplyLogic
during a withdrawal:
// note: `userConfig` is storage reference:
// DataTypes.UserConfigurationMap storage userConfig,
- bool isCollateral = userConfig.isUsingAsCollateral(reserve.id);
+ // read user's configuration once from storage; this cached copy will be used
+ // and updated by all withdraw operations, then written to storage once at
+ // the end ensuring only 1 read/write from/to storage
+ DataTypes.UserConfigurationMap memory userConfigCache = userConfig;
+ bool isCollateral = userConfigCache.isUsingAsCollateral(reserve.id);
if (isCollateral && amountToWithdraw == userBalance) {
- userConfig.setUsingAsCollateral(reserve.id, false);
+ userConfigCache.setUsingAsCollateralInMemory(reserve.id, false);
emit ReserveUsedAsCollateralDisabled(params.asset, msg.sender);
}
@@ -145,12 +150,12 @@ library SupplyLogic {
reserveCache.nextLiquidityIndex
);
- if (isCollateral && userConfig.isBorrowingAny()) {
+ if (isCollateral && userConfigCache.isBorrowingAny()) {
ValidationLogic.validateHFAndLtv(
reservesData,
reservesList,
eModeCategories,
- userConfig,
+ userConfigCache,
params.asset,
msg.sender,
params.reservesCount,
@@ -161,7 +166,10 @@ library SupplyLogic {
emit Withdraw(params.asset, msg.sender, params.to, amountToWithdraw);
+ // update user's configuration from cache; but only if it was modified
+ if (isCollateral && amountToWithdraw == userBalance) {
+ userConfig.data = userConfigCache.data;
}
Heuristic: Is a struct storage reference passed to multiple functions and modified during a single transaction? If so, can it be cached in memory, passed by reference, and written back to storage once at the end?
Context structs are often used to group function variables and avoid the dreaded “stack too deep” compiler error in Solidity. While helpful in complex functions, these structs are sometimes added by default even when not needed, resulting in unnecessary memory allocations and higher gas costs.
The fix [1, 2, 3] is straightforward: remove any unused or unnecessary context structs and inline their variables directly, as long as doing so doesn’t trigger a stack depth issue.
A simple case appears in the Collector
contract, where CreateStreamLocalVars
was used needlessly:
- CreateStreamLocalVars memory vars;
- vars.duration = stopTime - startTime;
+ uint256 duration = stopTime - startTime;
/* Without this, the rate per second would be zero. */
- if (deposit < vars.duration) revert DepositSmallerTimeDelta();
+ if (deposit < duration) revert DepositSmallerTimeDelta();
/* This condition avoids dealing with remainders */
- if (deposit % vars.duration > 0) revert DepositNotMultipleTimeDelta();
+ if (deposit % duration > 0) revert DepositNotMultipleTimeDelta();
- vars.ratePerSecond = deposit / vars.duration;
+ uint256 ratePerSecond = deposit / duration;
/* Create and store the stream object. */
streamId = _nextStreamId++;
_streams[streamId] = Stream({
remainingBalance: deposit,
deposit: deposit,
isEntity: true,
- ratePerSecond: vars.ratePerSecond,
+ ratePerSecond: ratePerSecond,
Heuristic: Is a context struct being used when it isn’t necessary? Can the variables be safely declared inside the function without causing a “stack too deep” error?
In cases where a context struct is genuinely needed to avoid a “stack too deep” error, it’s common for the struct to include more variables than necessary. This habit often persists from copy-pasting or overgeneralizing prior patterns.
The solution is simple: keep only the variables that must be in the struct to satisfy the compiler. Any that can safely be moved back into the function should be removed, as this reduces memory usage and can lead to lower gas costs.
In Aave, we successfully removed 3 variables from LiquidationCallLocalVars
and 5 variables from CalculateUserAccountDataVars
[1, 2], leading to measurable gas savings.
Heuristic: Can any variables in a context struct be moved into the function body without triggering a “stack too deep” error? If so, does doing so reduce gas usage? Always confirm the impact with snapshot comparisons.
In many contracts, counters are incremented by one when creating new items like rewards or streams. While functionally correct, separating the read and increment operations can result in redundant storage accesses.
When possible, it's more gas-efficient to read and increment the counter in the same statement (using x++
), especially if the value is only needed once.
We applied this optimization in two places [1, 2] in Aave’s RewardsDistributor
and Collector
contracts:
// RewardsDistributor
- _assets[rewardsInput[i].asset].availableRewardsCount
+ _assets[rewardsInput[i].asset].availableRewardsCount++
] = rewardsInput[i].reward;
- _assets[rewardsInput[i].asset].availableRewardsCount++;
// Collector
- uint256 streamId = _nextStreamId;
+ streamId = _nextStreamId++;
_streams[streamId] = Stream({
remainingBalance: deposit,
deposit: deposit,
@@ -271,9 +271,6 @@ contract Collector is AccessControlUpgradeable, ReentrancyGuardUpgradeable, ICol
tokenAddress: tokenAddress
});
- /* Increment the next stream id. */
- _nextStreamId++;
Heuristic: Does refactoring a storage counter to use x++ in the same expression reduce gas usage?
When a function is expected to return or revert early, it should perform only the minimal work required beforehand. Unnecessary computation, especially storage reads, should be avoided, particularly when the outcome depends on a single input parameter or a single storage slot.
Aave generally handles this well, prioritizing input checks and accessing storage only when needed. However, we found one case in RewardsDistributor
where a minor inefficiency remained:
uint256 assetUnit;
uint256 numAvailableRewards = _assets[asset].availableRewardsCount;
unchecked {
// @gas If the most common case is numAvailableRewards == 0, then a
// fast return will subsequently occur meaning this statement was useless.
// But when numAvailableRewards > 0 performance is better "as is" since
// availableRewardsCount & decimals are in the same storage slot and a fast
// return won't occur
assetUnit = 10 ** _assets[asset].decimals;
}
if (numAvailableRewards == 0) {
return;
}
Heuristic: Does the function do unnecessary work before a fast return or revert? Can it “fail fast” with less computation or fewer storage reads? Identify and defer any unnecessary operations when an early exit is likely.
A typical inefficiency in Solidity is copying an entire struct from storage to memory when only a few of its fields are used. This introduces unnecessary storage reads and increases gas costs.
While Aave generally avoids this mistake, we identified two cases [1, 2] in the Collector
contract, including a simple example in deltaOf
:
function deltaOf(uint256 streamId) public view streamExists(streamId) returns (uint256 delta) {
- Stream memory stream = _streams[streamId];
- if (block.timestamp <= stream.startTime) return 0;
- if (block.timestamp < stream.stopTime) return block.timestamp - stream.startTime;
- return stream.stopTime - stream.startTime;
+ Stream storage stream = _streams[streamId];
+ (uint256 startTime, uint256 stopTime) = (stream.startTime, stream.stopTime);
+ if (block.timestamp <= startTime) return 0;
+ if (block.timestamp < stopTime) return block.timestamp - startTime;
+ return stopTime - startTime;
This change avoids copying the full Stream
struct into memory and instead reads only the fields that are actually needed.
Heuristic: Is an entire struct copied from storage to memory, even though the function only accesses a few of its fields? If so, extract only the required fields to reduce gas usage.
Modifiers that perform storage reads can introduce redundant operations when the parent function also needs access to the same data. To avoid duplicate storage access, you can:
We applied this pattern in Aave by replacing the onlyAdminOrRecipient
modifier with an internal function. This reduced gas costs in two Collector
contract functions:
- modifier onlyAdminOrRecipient(uint256 streamId) {
- if (_onlyFundsAdmin() == false && msg.sender != _streams[streamId].recipient) {
- revert OnlyFundsAdminOrRecipient();
- }
- _;
- }
+ function _onlyAdminOrRecipient(address recipient) internal view {
+ if (!_onlyFundsAdmin() && msg.sender != recipient) {
+ revert OnlyFundsAdminOrRecipient();
+ }
+ }
In the calling functions:
function withdrawFromStream(
uint256 streamId,
uint256 amount
-) external nonReentrant streamExists(streamId) onlyAdminOrRecipient(streamId) returns (bool) {
+) external nonReentrant streamExists(streamId) returns (bool) {
if (amount == 0) revert InvalidZeroAmount();
- Stream memory stream = _streams[streamId];
+ Stream storage stream = _streams[streamId];
+ address recipient = stream.recipient;
+ _onlyAdminOrRecipient(recipient);
function cancelStream(
uint256 streamId
-) external nonReentrant streamExists(streamId) onlyAdminOrRecipient(streamId) returns (bool) {
- Stream memory stream = _streams[streamId];
+) external nonReentrant streamExists(streamId) returns (bool) {
+ Stream storage stream = _streams[streamId];
+ address recipient = stream.recipient;
+ _onlyAdminOrRecipient(recipient);
This change ensures that stream.recipient
is only read from storage once per function.
Heuristic: Does a modifier perform a storage read that the parent function also needs? If so, consider refactoring the modifier into an internal function and pass in the cached value to avoid redundant storage access.
For large, successful protocols, gas costs accumulate quickly, often amounting to millions of dollars over time. These costs are paid by users and ultimately flow to validators.
Smart contract developers can apply a range of safe, well-understood techniques to reduce gas usage without sacrificing clarity or maintainability, and without resorting to obscure assembly tricks.
To maximize impact, gas optimization audits should be performed before security reviews, allowing auditors to analyze the final, optimized version of the code.