Skip to main content

Command Palette

Search for a command to run...

The Yieldoor Gas Optimizoor

Learn to write gas-efficient Solidity code using a real-world example!

Updated
15 min read

Decorated auditor deadrosesxyz recently put on their developer hat to create a leveraged yield-farming protocol Yieldoor whose code provided ample opportunity for gas optimization. Many articles have been written listing common gas optimization techniques but this real-world case study illustrates 15.43% overall gas savings achieved via refactoring using a step-by-step mental process for optimizing core protocol functions.

Looking to work with me for a Gas Audit on your protocol? DMs are open!

We’ll use the publicly available contest source code and start by focusing on the most interesting, complicated and mission-critical liquidation function Leverager::liquidatePosition.

Measuring Gas Cost

Foundry provides a number of in-built tools to measure gas cost including:

  • gas reports via forge test --gas-report after configuration in foundry.toml for contract deployment and function call costs

  • test function snapshots via forge snapshot to measure the gas costs of the entire unit test suite

  • gas section snapshots via cheatcodes to measure gas costs of arbitrary code segments inside unit tests

We’ll fork the original source code and use the second and third options by:

  • editing test/Leverager.t.sol to add a test_Gas_* prefix around the three tests which call Leverager::liquidatePosition

  • changing test/BaseTest.sol to reduce fuzz runs to 100 by editing the relevant comments to /// forge-config: default.fuzz.runs = 100 to speed up the iterative optimization cycle

  • add “gas section snapshots” inside the tests functions around the calls to Leverager::liquidatePosition:

vm.startSnapshotGas("lendingPoolLiquidation");
ILeverager(leverager).liquidatePosition(liqParams);
vm.stopSnapshotGas();

vm.startSnapshotGas("liquidateWithSwap");
ILeverager(leverager).liquidatePosition(liqParams);
vm.stopSnapshotGas();

vm.startSnapshotGas("liquidationNoSwap");
ILeverager(leverager).liquidatePosition(liqParams);
vm.stopSnapshotGas();
  • get the initial “test function snapshots” by running forge snapshot --fork-url ETH_RPC_URL --fork-block-number 20956198 which generates a new file .gas-snapshot recording gas costs for all unit tests and overall gas cost (second option above)

  • get the initial “gas section snapshot" by running forge test --gas-snapshot-check=true --match-test test_Gas --fork-url ETH_RPC_URL --fork-block-number 20956198 which generates a file snapshots/LeveragerTest.json showing the initial gas cost for the manual snapshots wrapping calls to Leverager::liquidatePosition (third option above):

{
  "lendingPoolLiquidation": "575367",
  "liquidateWithSwap": "618080",
  "liquidationNoSwap": "439622"
}

Back up these files to .gas-snapshot.original and snapshots/LeveragerTest.original.json to maintain a starting point record for easy comparison at the end. After any code change we can then run:

  • forge snapshot --diff --fork-url ETH_RPC_URL --fork-block-number 20956198 to see whether the change reduced the per-test and overall all-tests gas costs. To override the existing .gas-snapshot file simply run it without the -diff flag. Important: all tests must pass for a new .gas-snapshot file to be generated

  • forge test --gas-snapshot-check=true --match-test test_Gas --fork-url ETH_RPC_URL --fork-block-number 20956198 to see whether the change reduced the gas costs of the manual snapshots. To override the existing file simply remove the old file before running this command via rm snapshots/LeveragerTest.json

Cache Identical Storage Reads

Generally the easiest and most “bang-for-buck” gas optimization is to cache identical storage reads to prevent re-reading the same value from storage multiple times. This can be resolved by:

  • if a contract is not upgradeable and a storage location is only ever set once in the constructor, the storage slot should be declared immutable

  • otherwise the storage slot can be read once, cached, and passed as a parameter where it is required both in the current function and to child functions

Inspecting Leverager::liquidatePosition shows some quick and easy wins:

Attempting to simply cache feeReceipient fails due to a “stack too deep” error. When caching storage it is more gas efficient to use local variables within a function but if that is not possible due to “stack too deep” errors then:

  • declare a struct LiquidateContext to cache storage reads for the liquidation function:

      struct LiquidateContext {
          address feeRecipient;
          address swapRouter;
      }
    
  • eliminate a local variable that is only read once by passing the result of IVault(up.vault).twapPrice() directly to _calculateTokenValues

  • declare a new local variable LiquidateContext memory ctx inside the liquidation function to store the cached storage reads

The changes and result are seen in commit 1661887; the gas cost reduced for one test but grew for the other two, though this is just the beginning where we are laying the groundwork for further optimizations.

It also makes sense to consolidate the Position memory pos variable inside our LiquidateContext struct and create a new _getLiquidateContext internal function to return the struct:

struct LiquidateContext {
    // read from positions[liqParams.id]
    Position pos;

    address feeRecipient;
    address swapRouter;
}

function _getLiquidateContext(uint256 _id) internal view returns(LiquidateContext memory ctx) {
    ctx.pos = positions[_id];
}

/// @notice Liquidates a certain leveraged position.
/// @dev Check the ILeverager contract for comments on all LiquidateParams arguments
/// @dev Does not support partial liquidations
/// @dev Collects fees first, in order to properly calculate whether a position is actually liquidateable
function liquidatePosition(LiquidateParams calldata liqParams) external collectFees(liqParams.id) nonReentrant {
    // read everything required from storage once
    LiquidateContext memory ctx = _getLiquidateContext(liqParams.id);

collectFees Modifier Reads Identical Storage Slots

Next examine the modifiers to see if they re-read the same storage slots; it is common for developers to write modifiers that read the same storage slots as the function body resulting in inefficient code. In this case there is only one modifier of interest collectFees which copies the entire Position into memory again resulting in 10 identical storage reads:

modifier collectFees(uint256 _id) {
    // @gas 10 identical storage reads since `Position` already read from
    // storage to memory inside `liquidatePosition` function
    Position memory up = positions[_id];
    address strat = IVault(up.vault).strategy();
    IStrategy(strat).collectFees();
    _;
}

Since the collectFees modifier is only called by the liquidatePosition function we can simply inline it to save 10 storage reads:

function liquidatePosition(LiquidateParams calldata liqParams) external nonReentrant {
    // read everything required from storage once
    LiquidateContext memory ctx = _getLiquidateContext(liqParams.id);

    // must collect fees before checking if a position is liquidatable
    IStrategy(IVault(ctx.pos.vault).strategy()).collectFees();

With this change all three tests are now more gas efficient than the original code:

$ more snapshots/LeveragerTest.json 
{
  "lendingPoolLiquidation": "574354",
  "liquidateWithSwap": "616824",
  "liquidationNoSwap": "438617"
}
$ more snapshots/LeveragerTest.original.json 
{
  "lendingPoolLiquidation": "575367",
  "liquidateWithSwap": "618080",
  "liquidationNoSwap": "439622"
}

isLiquidatable Function Reads Identical Storage Slots

Having finished with the modifiers, start working through the function body looking for child functions. The first encountered function is isLiquidatable; looking at its implementation it also copies the entire Position from storage into memory again resulting in another 10 identical storage reads:


function isLiquidatable(uint256 _id) public view returns (bool liquidatable) {
    // another 10 identical storage reads when called by `liquidatePosition`
    Position memory pos = positions[_id];

In the original code during every liquidation transaction Position was being copied into memory 3 times for 30 identical storage reads! Solve this by introducing an internal helper function _isLiquidatable which:

  • takes as input the cached LiquidateContext memory ctx

  • can be called by both liquidatePosition and isLiquidatable

The changes are seen in commits (143cec6, e960acf):

function liquidatePosition(LiquidateParams calldata liqParams) external nonReentrant {
    // read everything required from storage once
    LiquidateContext memory ctx = _getLiquidateContext(liqParams.id);

    // must collect fees before checking if a position is liquidatable
    IStrategy(IVault(ctx.pos.vault).strategy()).collectFees();

    require(_isLiquidateable(ctx), "isnt liquidateable");


function isLiquidateable(uint256 _id) public view returns (bool liquidateable) {
    liquidateable = _isLiquidateable(_getLiquidateContext(_id));
}

// gas: internal helper function saves reading positions[_id] multiple times during liquidations
function _isLiquidateable(LiquidateContext memory ctx) internal view returns (bool liquidateable) {
    VaultParams memory vp = vaultParams[ctx.pos.vault];

    uint256 vaultSupply = IVault(ctx.pos.vault).totalSupply();

    // Assuming a price of X, a LP position has its lowest value when the pool price is exactly X.
    // Any price movement, would actually overvalue the position.
    // For this reason, attackers cannot force a position to become liquidateable with a swap.
    (uint256 vaultBal0, uint256 vaultBal1) = IVault(ctx.pos.vault).balances();
    uint256 userBal0 = ctx.pos.shares * vaultBal0 / vaultSupply;
    uint256 userBal1 = ctx.pos.shares * vaultBal1 / vaultSupply;
    uint256 price = IVault(ctx.pos.vault).twapPrice();

    uint256 totalValueUSD = _calculateTokenValues(ctx.pos.token0, ctx.pos.token1, userBal0, userBal1, price);
    uint256 bPrice = IPriceFeed(pricefeed).getPrice(ctx.pos.denomination);
    uint256 totalDenom = totalValueUSD * (10 ** ERC20(ctx.pos.denomination).decimals()) / bPrice;

    uint256 bIndex = ILendingPool(lendingPool).getCurrentBorrowingIndex(ctx.pos.denomination);
    uint256 owedAmount = ctx.pos.borrowedAmount * bIndex / ctx.pos.borrowedIndex;

    /// here we make a calculation what would be the necessary collateral
    /// if we had the same borrowed amount, but at max leverage. Check docs for better explanation why.
    uint256 base = owedAmount * 1e18 / (vp.maxTimesLeverage - 1e18);
    base = base < ctx.pos.initCollateralValue ? base : ctx.pos.initCollateralValue;

    if (owedAmount > totalDenom || totalDenom - owedAmount < vp.minCollateralPct * base / 1e18) return true;
}

This results in further gas savings:

- [lendingPoolLiquidation] 574354572916
- [liquidateWithSwap] 616824615384
- [liquidationNoSwap] 438617437178

_isLiquidatable Function Copies All VaultParams

Next inspecting the _isLiquidatable function observe that it:

  • copies VaultParams from storage to memory

  • only uses maxTimesLeverage and minCollateralPct from VaultParams

Since VaultParams is defined as:

struct VaultParams {
    bool leverageEnabled;
    uint256 maxUsdLeverage;
    uint256 maxTimesLeverage;
    uint256 minCollateralPct;
    uint256 maxCumulativeBorrowedUSD;
    uint256 currBorrowedUSD;
}

This means that _isLiquidatable is performing 4 unnecessary storage reads every time it copies VaultParams from storage to memory! Fix this by:

  • expanding the LiquidateContext struct to contain the additional fields

  • adding code to _getLiquidateContext to cache those fields

  • changing _isLiquidatable to use the cached fields

// cache storage reads in memory
struct LiquidateContext {
    // read from positions[liqParams.id]
    Position pos;

    // read from vaultParams[pos.vault]
    uint256 maxTimesLeverage;
    uint256 minCollateralPct;

    address feeRecipient;
    address swapRouter;
}

function _getLiquidateContext(uint256 _id) internal view returns(LiquidateContext memory ctx) {
    ctx.pos = positions[_id];

    VaultParams storage vpRef = vaultParams[ctx.pos.vault];
    (ctx.maxTimesLeverage, ctx.minCollateralPct)
        = (vpRef.maxTimesLeverage, vpRef.minCollateralPct);
}

function _isLiquidatable(LiquidateContext memory ctx) internal view returns (bool liquidateable) {
    /* snip */
    uint256 base = owedAmount * 1e18 / (ctx.maxTimesLeverage - 1e18);
    base = base < ctx.pos.initCollateralValue ? base : ctx.pos.initCollateralValue;

    if (owedAmount > totalDenom || totalDenom - owedAmount < ctx.minCollateralPct * base / 1e18) return true;

Resulting in further gas savings:

- [lendingPoolLiquidation] 572916572396
- [liquidateWithSwap] 615384614864
- [liquidationNoSwap] 437178436658

pricefeed Identical Reads In liquidatePosition & _isLiquidatable

A common mistake developers make is re-reading identical values from storage in child functions called by parent functions; in this case:

  • liquidatePosition calls _isLiquidatable which reads pricefeed from storage into memory

  • liquidatePosition itself reads pricefeed again, resulting in a duplicate storage read

Fix this by:

  • adding priceFeed to struct LiquidateContext

  • reading it once inside getLiquidateContext

  • reading it from the cached copy in _isLiquidatable and liquidatePosition

This again reduces the gas costs across the board:

- [lendingPoolLiquidation] 572396572345
- [liquidateWithSwap] 614864614813
- [liquidationNoSwap] 436658436608

pricefeed Identical Reads In _calculateTokenValues

Continuing to examine the _isLiquidatable child function, observe it:

  • calls _calculateTokenValues which is also called by the main function liquidatePosition

  • _calculateTokenValues reads the same identical value of pricefeed from storage up to 4 times:

if (IPriceFeed(pricefeed).hasPriceFeed(token0)) {
    chPrice0 = IPriceFeed(pricefeed).getPrice(token0);
    usdValue += amount0 * chPrice0 / decimals0;
}
if (IPriceFeed(pricefeed).hasPriceFeed(token1)) {
    chPrice1 = IPriceFeed(pricefeed).getPrice(token1);
    usdValue += amount1 * chPrice1 / decimals1;
}

This is easily fixed by changing _calculateTokenValues to take the cached pricefeed as input, resulting in even greater gas savings:

- [lendingPoolLiquidation] 572345571293
- [liquidateWithSwap] 614813613761
- [liquidationNoSwap] 436608435556

Leverager Further Optimizations

Further but potentially unsafe gas optimizations could be made by:

  • caching the result of external calls such as ILendingPool(lendingPool).getCurrentBorrowingIndex(ctx.pos.denomination)

  • using this to cache the calculated owedAmount

Ideally duplicate computations and external calls should be cached if it is safe to do so, but this would be unsafe if it is intended that later duplicate computations and external calls can return different values. Caching is only a safe optimization when the same value is read from storage (or received from an external call) multiple times.

Additional safe gas optimizations made to other Leverager functions include:

Leverager High Finding - feeRecipient Never Set

One heuristic to look for when doing gas optimizations in non-upgradeable contracts is storage slots which can be set to immutable if they are only set once in the constructor. When checking all Leverager storage slots I found that feeRecipient is never set anywhere meaning that either:

  • all the fees will be sent to the zero address for tokens which allow this, or

  • liquidation will be bricked for tokens which revert when sending tokens to the zero address

Strategy High Finding - Incorrect Upper Tick in collectFees

While looking for identical storage reads to cache I noticed this code block where the third call to collectPositionFees was re-reading mainPosition.tickUpper from storage when it likely shouldn’t be:

if (mainPosition.liquidity != 0) collectPositionFees(mainPosition.tickLower, mainPosition.tickUpper);
if (secondaryPosition.liquidity != 0) {
    collectPositionFees(secondaryPosition.tickLower, secondaryPosition.tickUpper);
}
if (ongoingVestingPosition) {
    // @audit `mainPosition.tickUpper` likely not intended to be read from
    // storage again here, should be `vestPosition.tickUpper`
    collectPositionFees(vestPosition.tickLower, mainPosition.tickUpper);
}

The impact is that Strategy::collectFees may revert or fail to collect the correct fees since it is passing the wrong upper tick.

LendingPool Gas Optimizations

Similar gas optimizations can be made to the LendingPool contract including:

Strategy Gas Optimizations

The Strategy contract can also benefit from the following gas optimizations:

Vault Gas Optimizations

The Vault contract was improved with some minor gas optimizations:

Internal Library Gas Optimizations

Internal libraries which execute functions over storage references also benefit from gas optimizations:

Interim Gas Optimization Results

Run forge snapshot --diff --fork-url ETH_RPC_URL --fork-block-number 20956198 to get the “test function snapshots” gas output:

test_canDepositAtAnyPrice(uint256,uint256,uint256) (gas: -9887 (-1.166%)) 
test_canDepositAtAnyPrice(uint256,uint256,uint256) (gas: -9931 (-1.170%)) 
test_compound() (gas: -15758 (-1.207%)) 
test_compound() (gas: -15758 (-1.207%)) 
test_addVestingPosition() (gas: -19229 (-1.217%)) 
test_addVestingPosition() (gas: -19229 (-1.217%)) 
test_fuzzDepositAndWithdraw(uint256,uint256) (gas: -10083 (-1.376%)) 
test_fuzzDepositAndWithdraw(uint256,uint256) (gas: -10086 (-1.376%)) 
test_Gas_LendingPool() (gas: -40343 (-1.465%)) 
test_fuzzRebalance(uint256,uint256) (gas: -11360 (-1.478%)) 
test_fuzzRebalance(uint256,uint256) (gas: -11361 (-1.478%)) 
test_cantOpenPositionDueToVolatileVault() (gas: -14377 (-1.562%)) 
test_fuzzDepositWithdrawRemainRatio(uint256) (gas: -19643 (-1.634%)) 
test_fuzzDepositWithdrawRemainRatio(uint256) (gas: -19660 (-1.636%)) 
testDeposit() (gas: -16235 (-1.735%)) 
testDeposit() (gas: -16236 (-1.735%)) 
test_LendingPoolRepaymentFromBorrower() (gas: -30923 (-1.738%)) 
test_LendingPoolRepaymentAndClaim() (gas: -30923 (-1.802%)) 
test_borrowInThirdToken() (gas: -62728 (-2.585%)) 
test_partialWithdraw() (gas: -67099 (-2.880%)) 
test_vaultLimitsWork2() (gas: -55670 (-2.916%)) 
test_fuzzWithdraw(uint256) (gas: -10889 (-2.960%)) 
test_fuzzWithdraw(uint256) (gas: -10893 (-2.961%)) 
test_vaultLimitsWork1() (gas: -61307 (-3.212%)) 
test_Gas_liquidationNoSwap() (gas: -84678 (-3.814%)) 
test_Gas_liquidationWithSwap() (gas: -85708 (-3.840%)) 
test_cantOpenPositionWhenReserveInactive() (gas: -68329 (-4.091%)) 
test_lendingRate() (gas: -70875 (-4.276%)) 
test_permissionedFunctions() (gas: -108575 (-4.305%)) 
test_borrowToken1() (gas: -69476 (-4.594%)) 
test_cantOpenDueToPoolPriceOff() (gas: 0 (NaN%)) 
Overall gas change: -1077249 (-2.503%)

So far across the entire test suite we’ve reduced gas costs by 1.166% → 4.594% with an overall gas reduction of 2.503% saving 1,077,249 gas, largely by refactoring the existing Solidity code to reduce the amount of identical storage reads.

External Library Gas Optimizations

The protocol uses a number of external libraries from OpenZeppelin. Further gas savings can be realized by changing the contracts to use similar functions from Solady. First install Solady using:

  • forge install Vectorized/solady

  • add "@solady/=lib/solady/src/", to foundry.toml remappings

Next systematically change the protocol’s contracts to use Solady libraries instead of OpenZeppelin:

With the optimized external libraries run forge snapshot --diff --fork-url ETH_RPC_URL --fork-block-number 20956198 to refresh the “test function snapshots” gas output:

test_compound() (gas: -20293 (-1.555%)) 
test_compound() (gas: -20293 (-1.555%)) 
test_canDepositAtAnyPrice(uint256,uint256,uint256) (gas: -14501 (-1.711%)) 
test_addVestingPosition() (gas: -28079 (-1.777%)) 
test_addVestingPosition() (gas: -28080 (-1.777%)) 
test_canDepositAtAnyPrice(uint256,uint256,uint256) (gas: -15237 (-1.796%)) 
test_fuzzRebalance(uint256,uint256) (gas: -15522 (-2.019%)) 
test_fuzzRebalance(uint256,uint256) (gas: -15522 (-2.019%)) 
test_fuzzDepositAndWithdraw(uint256,uint256) (gas: -15992 (-2.183%)) 
test_fuzzDepositAndWithdraw(uint256,uint256) (gas: -15994 (-2.183%)) 
test_fuzzDepositWithdrawRemainRatio(uint256) (gas: -27003 (-2.247%)) 
test_fuzzDepositWithdrawRemainRatio(uint256) (gas: -27004 (-2.247%)) 
testDeposit() (gas: -21996 (-2.351%)) 
testDeposit() (gas: -21996 (-2.351%)) 
test_cantOpenPositionDueToVolatileVault() (gas: -27920 (-3.034%)) 
test_Gas_LendingPool() (gas: -85556 (-3.107%)) 
test_fuzzWithdraw(uint256) (gas: -12440 (-3.382%)) 
test_fuzzWithdraw(uint256) (gas: -12444 (-3.382%)) 
test_permissionedFunctions() (gas: -94015 (-3.728%)) 
test_LendingPoolRepaymentFromBorrower() (gas: -78629 (-4.418%)) 
test_LendingPoolRepaymentAndClaim() (gas: -78069 (-4.548%)) 
test_partialWithdraw() (gas: -125069 (-5.367%)) 
test_borrowInThirdToken() (gas: -135182 (-5.570%)) 
test_lendingRate() (gas: -92864 (-5.602%)) 
test_vaultLimitsWork2() (gas: -110189 (-5.773%)) 
test_borrowToken1() (gas: -89333 (-5.907%)) 
test_cantOpenPositionWhenReserveInactive() (gas: -98835 (-5.917%)) 
test_vaultLimitsWork1() (gas: -115929 (-6.073%)) 
test_Gas_liquidationNoSwap() (gas: -136030 (-6.126%)) 
test_Gas_liquidationWithSwap() (gas: -138334 (-6.198%)) 
test_cantOpenDueToPoolPriceOff() (gas: 0 (NaN%)) 
Overall gas change: -1718350 (-3.992%)

Optimizing the external libraries increased:

  • minimum gas saving from 1.166% to 1.555%

  • maximum gas saving from 4.594% to 6.198%

  • overall gas saving from 2.503% to 3.992%, saving 1,718,350 gas instead of only 1,077,249 which is a nice 37% improvement!

Enabling The Optimizer

We can do even better by adding the following to foundry.toml to enable the optimizer:

optimizer = true
optimizer_runs = 1_000_000

Now running forge snapshot --diff --fork-url ETH_RPC_URL --fork-block-number 20956198 to refresh the “test function snapshots” gas output gives:

test_fuzzWithdraw(uint256) (gas: -29626 (-8.053%)) 
test_fuzzWithdraw(uint256) (gas: -29628 (-8.054%)) 
test_compound() (gas: -139490 (-10.689%)) 
test_compound() (gas: -139525 (-10.691%)) 
test_fuzzDepositWithdrawRemainRatio(uint256) (gas: -143294 (-11.923%)) 
test_fuzzDepositWithdrawRemainRatio(uint256) (gas: -143340 (-11.927%)) 
test_addVestingPosition() (gas: -201759 (-12.766%)) 
test_addVestingPosition() (gas: -201764 (-12.767%)) 
test_Gas_LendingPool() (gas: -390653 (-14.188%)) 
test_canDepositAtAnyPrice(uint256,uint256,uint256) (gas: -120991 (-14.260%)) 
test_canDepositAtAnyPrice(uint256,uint256,uint256) (gas: -120980 (-14.271%)) 
test_borrowInThirdToken() (gas: -354141 (-14.593%)) 
test_partialWithdraw() (gas: -341000 (-14.634%)) 
testDeposit() (gas: -137266 (-14.672%)) 
testDeposit() (gas: -137286 (-14.673%)) 
test_LendingPoolRepaymentAndClaim() (gas: -261501 (-15.235%)) 
test_cantOpenPositionDueToVolatileVault() (gas: -141161 (-15.338%)) 
test_LendingPoolRepaymentFromBorrower() (gas: -273610 (-15.374%)) 
test_fuzzRebalance(uint256,uint256) (gas: -119467 (-15.540%)) 
test_fuzzRebalance(uint256,uint256) (gas: -119520 (-15.546%)) 
test_Gas_liquidationWithSwap() (gas: -351131 (-15.731%)) 
test_vaultLimitsWork2() (gas: -300941 (-15.765%)) 
test_vaultLimitsWork1() (gas: -305623 (-16.010%)) 
test_fuzzDepositAndWithdraw(uint256,uint256) (gas: -120873 (-16.496%)) 
test_fuzzDepositAndWithdraw(uint256,uint256) (gas: -120924 (-16.502%)) 
test_Gas_liquidationNoSwap() (gas: -367226 (-16.539%)) 
test_lendingRate() (gas: -286293 (-17.271%)) 
test_borrowToken1() (gas: -265396 (-17.548%)) 
test_cantOpenPositionWhenReserveInactive() (gas: -293424 (-17.566%)) 
test_permissionedFunctions() (gas: -684835 (-27.154%)) 
test_cantOpenDueToPoolPriceOff() (gas: 0 (NaN%)) 
Overall gas change: -6642668 (-15.434%)

Enabling the optimizer has provided major gas improvements across the board, increasing:

  • minimum gas saving from 1.555% to 8.053%

  • maximum gas saving from 6.198% to 27.154%

  • overall gas saving from 3.992% to 15.434%, saving 6,642,668 gas instead of only 1,718,350 resulting in an amazing 3.86x improvement!

Finally let’s get the “gas section snapshots” we put around the key Leverager::liquidatePosition function by running forge test --gas-snapshot-check=true --match-test test_Gas --fork-url ETH_RPC_URL --fork-block-number 20956198:

- [lendingPoolLiquidation] 575367484711 (-15.75%)
- [liquidateWithSwap] 618080536423 (-13.211%)
- [liquidationNoSwap] 439622365286 (-16.909%)

We’ve reduced the gas cost of calling Leverager::liquidatePosition by anywhere from 13.211% to 16.909% which matches well with the previously observed overall gas reduction of 15.434%.

Looking to work with me for a Gas Audit on your protocol? DMs are open!

Additional Resources

Deep Dives

Part 2 of 20

Deep dives on vulnerability classes exploited in real-world smart contract attacks

Up next

DeFi Liquidation Vulnerabilities

Liquidation code can contain subtle bugs and vulnerabilities..