Overview

If you’re like me, you’ve noticed gas costs becoming inordinately expensive when interacting with Convex. So I decided to investigate what was going on.

What I found was an extremely inefficient code path which actually had nothing to do with Convex contracts at all - despite it having an outsized impact on their users specifically. In fact, the gas guzzling code turned out to be related to a niche feature in Curve’s LP gauges.

To visualize it, I analyzed data from every Convex deposit over the last ~18 months, and the gas consumption of each.

Alt text Gas cost of each deposit transaction sent to Convex in the last 18 months.

As you can see, the average cost of a deposit had been growing steadily for more than a year from late 2022, increasing by 123% to nearly 1.25M gas per transaction by December this year.

Spoiler alert: I was able to permissionlessly fix the issue myself, dropping gas consumption for Convex deposits and withdrawals down to their original levels.

This post will provide a technical overview of what caused this ramping of gas costs, why it affected Convex more than others, and what the fix was. We’ll finish with some suggestions for how Curve might mitigate this in the future.

Background

Every Convex deposit routes a user’s LPs into their respective Curve gauge. The process is routine. However, as execution is handed over to the Curve gauge via deposit, withdraw or claim, it is possible for a significant jump in gas costs when certain conditions are in place.

Curve invented a concept of “boost” which allows people who lock more CRV to earn higher emissions on the same sized LP position. A couple years ago, they took this concept further by introducing a clever mechanism called “boost delegation”, which allows users who may have unutilized boost to delegate it to others. This opened the possibility for boost marketplaces like Warden to emerge.

This boost delegation feature was built into new gauges and requires a few additional operations during each user interaction. In the context of Convex’s gas issue we’re discussing today, two key contracts played a role:

  1. Boost Delegation V2. This contract tracks all boost delegations. Under the hood, when a delegation is made, this contract saves the data and performs the math to apply a modified veCRV balance to the users involved. The adjusted_balance_of(_user) function it exposes returns this modified amount.
  2. veBoost Patch. As the name suggests, this contract is a patch. It was introduced at block 17,964,967 to fix an unrelated issue (outlined here), but as we’ll see, it also contributed to the problem.

The Problem

When a gauge checks for a user’s adjusted balance, the call gets routed through multiple contracts. Importantly, the call reaches Boost Delegation V2 and arrives at a function called _checkpoint_read() on each check for adjusted balance. By glancing at this code, you can probably guess where this is going…

@view
@internal
def _checkpoint_read(_user: address, _delegated: bool) -> Point:
    ...

    ts: uint256 = (point.ts / WEEK) * WEEK
    for _ in range(255):
        ts += WEEK

        dslope: uint256 = 0
        if block.timestamp < ts:
            ts = block.timestamp
        else:
            if _delegated:
                dslope = self.delegated_slope_changes[_user][ts]
            else:
                dslope = self.received_slope_changes[_user][ts]

        point.bias -= point.slope * (ts - point.ts)
        point.slope -= dslope
        point.ts = ts
    ...

    return point

The for loop must be entered twice on each gauge interaction as it checks for each user’s boost sent, and again for his boost received. Certainly this threatens to be expensive, but a close look at the surrounding code shows that some unique conditions are required in order for a user to find themselves stuck in this loop.

Specifically, a user must have an update (or Point) written in the past via them sending or receiving boost from another user. The problem grows more pronounced as the number of weeks since the last Point write increases, forcing the view function to compute the data for each missing week. Each week that passes adds an extra two SLOADs (2 * 2,100 gas) plus a few math operations.

At the time of my research, it had been 71 weeks (!!!) since the last update had ocurred for both Yearn and Convex’s veCRV position. And sure enough, as we can see in the chart above, the average cost to transact is trending upwards as new weeks add iterations to the loop.

But the problem became even more acute for Convex users in late August 2023, as you can see in the chart above where the smooth trend upwards in gas prices suddenly becomes disjointed, leaping upward. This is when the patch contract mentioned above was implemented. Taking a quick look at the code, you’ll notice why immediately.

@view
@external
def adjusted_balance_of(_user: address) -> uint256:
    if _user == CONVEX_WALLET:
        return ERC20(BOOST_V2).balanceOf(CONVEX_WALLET) - BoostV2(BOOST_V2).delegated_balance(YEARN_WALLET)
    
    if _user == YEARN_WALLET:
        return ERC20(VE).balanceOf(YEARN_WALLET)

    return ERC20(BOOST_V2).balanceOf(_user)

As you can see, it forces each query for Convex’s adjusted_balance() to also involve an additional query to Yearn’s boost, effectively multiplying the amount of work needing to be done as Yearn’s position also must traverse a large number of weeks.

In this example transaction, we can profile its gas usage on Tenderly. It’s almost difficult to see, but you can notice over 100 SLOADs along the bottom row which should be a huge red flag.

Alt text

The Fix

Luckily I found that within the Boost Delegation V2 contract, Curve exposes a handy checkpoint_user function which is totally permissionless to call. It does exactly what we need in order to fix the problem: loops through all the unfilled weeks, and populates them with data.

And just like that, two ~$85 transactions (one for Convex, and one for Yearn) fixed our problem! Several weeks later, we can see the impact it’s had…

Alt text Gas cost of each deposit transaction sent to Convex, highlighting patch and checkpoint blocks.

Notice:

  • The less frequent dots tha appear along the bottom, even before the checkpoint, represent deposits to older gauges which do not support delegation. These transactions bypass the issue entirely.
  • We can now get gas on each transaction significantly below 1M again to an average of 0.6M.

Proposed Long Term Solutions

Because the problem is multi-faceted, there are several things Curve might consider to permanently address this.

  1. First and foremost, the patch contract should be deprecated in favor of a new Boost Delegation V3 contract which properly addresses the issue it was attempting to solve in the first place. This can be done by simply redeploying the contract.
  2. In all future gauge codebases, create a “write” counterpart for the adjusted_balance_of() function, which should populate any unfilled weeks on each user interaction. This will enforce that no two users in the same week will have to traverse many iterations of the for loop.