AAVE Source Code Analysis: Interest Rate Code Walkthrough

Summary
Several parameters in AAVE's interest model interact with each other in fairly complex ways, especially around stable borrowing.

Several parameters inside the AAVE interest-rate model interact with one another in fairly complex ways. Stable-rate borrowing is especially tricky, and the formula for the average stable rate is particularly hard to read. In real lending markets, however, many assets do not even support stable-rate borrowing, and among the assets that do, the share of stable-rate debt is very small, often well below 1%. So for beginners, it is completely reasonable to skip the stable-rate part on a first pass.

There are a few points worth highlighting in AAVE’s interest-rate logic:

  1. AAVE accrues interest based on timestamps, while Compound accrues interest based on block numbers. In both protocols, rate updates are triggered by actions such as deposit, withdraw, borrow, and repay, and they are only meaningfully updated once per block.
  2. Deposit interest grows linearly, while borrowing interest grows in compound form, i.e. exponentially over time.
  3. A percentage of borrowing income, the reserve factor (10% by default), is routed into the protocol treasury.
  4. The balanceOf method on aToken and debtToken returns the amount of underlying deposit or debt, not merely the raw token share amount.

If all you need is the high-level picture, the core AAVE flows can be summarized like this. The relevant code lives mainly in the LendingPool contract.

Deposit

  1. Validate data with ValidationLogic.validateDeposit
  2. Update state via updateState
  3. Update rates via updateInterestRates
  4. Transfer the user’s token into the LendingPool
  5. Mint aTokens to the user: aToken amount = token amount / liquidityIndex
  6. Emit the Deposit event

Withdraw

  1. Query the user’s underlying token balance. Note that this is the underlying token amount, not the raw aToken amount. Even though this path calls aToken.balanceOf(), that function returns the current block’s underlying-equivalent amount.
  2. Validate data with ValidationLogic.validateWithdraw
  3. Update state via updateState
  4. Update rates via updateInterestRates
  5. Burn the user’s aTokens and transfer the corresponding underlying tokens back
  6. Emit the Withdraw event

Borrow

  1. Use the price oracle to calculate the ETH-denominated value of the borrow amount, amountInETH. In AAVE, all assets are normalized into ETH value for risk calculations.
  2. Validate data with ValidationLogic.validateBorrow
  3. Update state via updateState
  4. Mint the relevant debt token. This is a critical step: AAVE records debt through debt tokens. Each asset can have both a StableDebtToken and a VariableDebtToken.
  5. Update rates via updateInterestRates
  6. Transfer the borrowed underlying token to the user
  7. Emit the Borrow event

Repay

  1. Query the stable-rate and variable-rate debt amounts, both measured in underlying token amount through the debt tokens
  2. Validate data with ValidationLogic.validateRepay
  3. Update state via updateState
  4. Burn debt tokens. A very important detail here is the repayment amount calculation, paybackAmount. This part must be handled extremely carefully, because mistakes here can cause major losses.
  5. Update rates via updateInterestRates
  6. Transfer the user’s underlying token into the contract to complete repayment
  7. Emit the Repay event

Those are the most basic operations. Other operations include liquidation, which deserves its own discussion, and rate-mode switching between stable and variable debt.

From the flow above, two things are clear:

  • updateState and updateInterestRates are two critical functions that every major action calls
  • the call order differs slightly by operation; for example, deposit and withdraw call them consecutively, while borrow inserts extra logic in between. Why?

All rate changes are calculated and propagated through updateState and updateInterestRates, so let’s look at those two functions first.

updateState and updateInterestRates

The function signatures are:

1
2
3
4
5
6
7
8
9
function updateState(DataTypes.ReserveData storage reserve) internal;

function updateInterestRates(
    DataTypes.ReserveData storage reserve,
    address reserveAddress,
    address aTokenAddress,
    uint256 liquidityAdded,
    uint256 liquidityTaken
  ) internal;

These functions live in the ReserveLogic.sol library. Both receive DataTypes.ReserveData storage reserve as a parameter. We already discussed ReserveData earlier: it is the struct that stores all rate-related fields. Because the parameter is passed as storage, these functions can modify protocol state directly.

updateState

The main responsibilities of updateState are: 0. fetch the latest variable-rate debt amount, i.e. the total supply of VariableDebtToken

  1. update the deposit interest index ReserveData.liquidityIndex and the variable borrow index ReserveData.variableBorrowIndex through _updateIndexes
  2. route part of newly accrued borrowing income, based on the reserve factor, into the treasury by minting aTokens to the treasury address

updateInterestRates

The main responsibilities of updateInterestRates are: 0. update currentLiquidityRate

  1. update currentStableBorrowRate
  2. update currentVariableBorrowRate

Key Functions

_updateIndexes

One subtle point is that although this function is called whenever liquidity changes, within a single block only the first call actually changes liquidityIndex and variableBorrowIndex. Later calls in the same block do not change them because the timestamp is the same, and both calculateCompoundedInterest and calculateLinearInterest return Ray(1e27) for zero elapsed time.

I once wondered whether this could be optimized and opened an issue about it: https://github.com/aave/protocol-v2/issues/237

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
  function _updateIndexes(
    DataTypes.ReserveData storage reserve,
    uint256 scaledVariableDebt,
    uint256 liquidityIndex,
    uint256 variableBorrowIndex,
    uint40 timestamp  // previous update timestamp
  ) internal returns (uint256, uint256) {
    // currentLiquidityRate is updated inside updateInterestRates
    uint256 currentLiquidityRate = reserve.currentLiquidityRate;

    uint256 newLiquidityIndex = liquidityIndex;
    uint256 newVariableBorrowIndex = variableBorrowIndex;

    // only accumulate if the reserve is actually generating income
    if (currentLiquidityRate > 0) {
      // deposit-side interest accrues linearly according to currentLiquidityRate
      // formula: cumulatedLiquidityInterest = currentLiquidityRate * (block.timestamp - timestamp) / SECONDS_PER_YEAR
      uint256 cumulatedLiquidityInterest =
        MathUtils.calculateLinearInterest(currentLiquidityRate, timestamp);
      newLiquidityIndex = cumulatedLiquidityInterest.rayMul(liquidityIndex);
      require(newLiquidityIndex <= type(uint128).max, Errors.RL_LIQUIDITY_INDEX_OVERFLOW);

      // update deposit interest index
      reserve.liquidityIndex = uint128(newLiquidityIndex);

      // as the liquidity rate might come only from stable-rate debt,
      // ensure there is actual variable debt before compounding it
      if (scaledVariableDebt != 0) {
        // compound the variable borrowing interest accrued in this time interval
        // the implementation uses a Taylor-series approximation
        uint256 cumulatedVariableBorrowInterest =
          MathUtils.calculateCompoundedInterest(reserve.currentVariableBorrowRate, timestamp);
        newVariableBorrowIndex = cumulatedVariableBorrowInterest.rayMul(variableBorrowIndex);
        require(
          newVariableBorrowIndex <= type(uint128).max,
          Errors.RL_VARIABLE_BORROW_INDEX_OVERFLOW
        );
        // update variable borrow index
        reserve.variableBorrowIndex = uint128(newVariableBorrowIndex);
      }
    }

    // update timestamp
    //solium-disable-next-line
    reserve.lastUpdateTimestamp = uint40(block.timestamp);
    return (newLiquidityIndex, newVariableBorrowIndex);
  }

updateInterestRates

This function updates the annualized rates:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
  function updateInterestRates(
    DataTypes.ReserveData storage reserve,
    address reserveAddress,
    address aTokenAddress,
    uint256 liquidityAdded,
    uint256 liquidityTaken
  ) internal {
    UpdateInterestRatesLocalVars memory vars;

    vars.stableDebtTokenAddress = reserve.stableDebtTokenAddress;

    // stable-rate debt
    (vars.totalStableDebt, vars.avgStableRate) = IStableDebtToken(vars.stableDebtTokenAddress)
      .getTotalSupplyAndAvgRate();

    // variable-rate debt, converted into underlying token amount
    // calculates total variable debt using scaledTotalSupply instead of totalSupply()
    // because it is cheaper, and the index has already been updated by updateState()
    vars.totalVariableDebt = IVariableDebtToken(reserve.variableDebtTokenAddress)
      .scaledTotalSupply()
      .rayMul(reserve.variableBorrowIndex);

    // calculate the new deposit, stable borrow, and variable borrow annualized rates
    // calculateInterestRates is discussed below
    (
      vars.newLiquidityRate,
      vars.newStableRate,
      vars.newVariableRate
    ) = IReserveInterestRateStrategy(reserve.interestRateStrategyAddress).calculateInterestRates(
      reserveAddress,
      aTokenAddress,
      liquidityAdded,
      liquidityTaken,
      vars.totalStableDebt,
      vars.totalVariableDebt,
      vars.avgStableRate,
      reserve.configuration.getReserveFactor()
    );
    require(vars.newLiquidityRate <= type(uint128).max, Errors.RL_LIQUIDITY_RATE_OVERFLOW);
    require(vars.newStableRate <= type(uint128).max, Errors.RL_STABLE_BORROW_RATE_OVERFLOW);
    require(vars.newVariableRate <= type(uint128).max, Errors.RL_VARIABLE_BORROW_RATE_OVERFLOW);

    // set new rates
    reserve.currentLiquidityRate = uint128(vars.newLiquidityRate);
    reserve.currentStableBorrowRate = uint128(vars.newStableRate);
    reserve.currentVariableBorrowRate = uint128(vars.newVariableRate);

    emit ReserveDataUpdated(
      reserveAddress,
      vars.newLiquidityRate,
      vars.newStableRate,
      vars.newVariableRate,
      reserve.liquidityIndex,
      reserve.variableBorrowIndex
    );
  }

calculateInterestRates

The relevant code is:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
  struct CalcInterestRatesLocalVars {
    uint256 totalDebt;
    uint256 currentVariableBorrowRate;
    uint256 currentStableBorrowRate;
    uint256 currentLiquidityRate;
    uint256 utilizationRate;
  }

  /**
   * @dev Calculates the interest rates depending on the reserve's state and configurations.
   * NOTE This function is kept for compatibility with the previous DefaultInterestRateStrategy interface.
   * New protocol implementation uses the new calculateInterestRates() interface
   * @param reserve The address of the reserve
   * @param availableLiquidity The liquidity available in the corresponding aToken
   * @param totalStableDebt The total borrowed from the reserve at a stable rate
   * @param totalVariableDebt The total borrowed from the reserve at a variable rate
   * @param averageStableBorrowRate The weighted average of all stable-rate loans
   * @param reserveFactor The portion of interest that goes to the market treasury
   * @return The liquidity rate, the stable borrow rate and the variable borrow rate
   **/
  function calculateInterestRates(
    address reserve,
    uint256 availableLiquidity,
    uint256 totalStableDebt,
    uint256 totalVariableDebt,
    uint256 averageStableBorrowRate,
    uint256 reserveFactor
  )
    public
    view
    override
    returns (
      uint256,
      uint256,
      uint256
    )
  {
    CalcInterestRatesLocalVars memory vars;

    // total debt = variable debt + stable debt
    vars.totalDebt = totalStableDebt.add(totalVariableDebt);
    vars.currentVariableBorrowRate = 0;
    vars.currentStableBorrowRate = 0;
    vars.currentLiquidityRate = 0;

    // utilization = total borrows / (available liquidity + total borrows)
    // ignore rayDiv/rayMul for intuition; you can think of them roughly as div/mul
    vars.utilizationRate = vars.totalDebt == 0
      ? 0
      : vars.totalDebt.rayDiv(availableLiquidity.add(vars.totalDebt));

    vars.currentStableBorrowRate = ILendingRateOracle(addressesProvider.getLendingRateOracle())
      .getMarketBorrowRate(reserve);

    // two-slope rate curve
    // rate calculation when utilization is above the optimal point
    if (vars.utilizationRate > OPTIMAL_UTILIZATION_RATE) {
      uint256 excessUtilizationRateRatio =
        vars.utilizationRate.sub(OPTIMAL_UTILIZATION_RATE).rayDiv(EXCESS_UTILIZATION_RATE);

      vars.currentStableBorrowRate = vars.currentStableBorrowRate.add(_stableRateSlope1).add(
        _stableRateSlope2.rayMul(excessUtilizationRateRatio)
      );

      vars.currentVariableBorrowRate = _baseVariableBorrowRate.add(_variableRateSlope1).add(
        _variableRateSlope2.rayMul(excessUtilizationRateRatio)
      );
    } else {
      vars.currentStableBorrowRate = vars.currentStableBorrowRate.add(
        _stableRateSlope1.rayMul(vars.utilizationRate.rayDiv(OPTIMAL_UTILIZATION_RATE))
      );
      vars.currentVariableBorrowRate = _baseVariableBorrowRate.add(
        vars.utilizationRate.rayMul(_variableRateSlope1).rayDiv(OPTIMAL_UTILIZATION_RATE)
      );
    }

    // currentLiquidityRate = (weighted average borrow rate / total debt) * utilization * (1 - reserve factor)
    // weighted average borrow rate = (variable debt * variable rate + stable debt * average stable rate) / total debt
    vars.currentLiquidityRate = _getOverallBorrowRate(
      totalStableDebt,
      totalVariableDebt,
      vars.currentVariableBorrowRate,
      averageStableBorrowRate
    )
      .rayMul(vars.utilizationRate)
      .percentMul(PercentageMath.PERCENTAGE_FACTOR.sub(reserveFactor));

    return (
      vars.currentLiquidityRate,
      vars.currentStableBorrowRate,
      vars.currentVariableBorrowRate
    );
  }

  /**
   * @dev Calculates the overall borrow rate as the weighted average of variable debt and stable debt
   * @param totalStableDebt The total borrowed at a stable rate
   * @param totalVariableDebt The total borrowed at a variable rate
   * @param currentVariableBorrowRate The current variable borrow rate
   * @param currentAverageStableBorrowRate The weighted average of all stable-rate loans
   * @return The weighted average borrow rate
   * weighted average borrow rate: (variable debt * variable rate + stable debt * average stable rate) / total debt
   **/
  function _getOverallBorrowRate(
    uint256 totalStableDebt,
    uint256 totalVariableDebt,
    uint256 currentVariableBorrowRate,
    uint256 currentAverageStableBorrowRate
  ) internal pure returns (uint256) {
    uint256 totalDebt = totalStableDebt.add(totalVariableDebt);

    if (totalDebt == 0) return 0;

    uint256 weightedVariableRate = totalVariableDebt.wadToRay().rayMul(currentVariableBorrowRate);

    uint256 weightedStableRate = totalStableDebt.wadToRay().rayMul(currentAverageStableBorrowRate);

    uint256 overallBorrowRate =
      weightedVariableRate.add(weightedStableRate).rayDiv(totalDebt.wadToRay());

    return overallBorrowRate;
  }
}

Why updateState Comes Before updateInterestRates

In simple terms, liquidityIndex and variableBorrowIndex are derived from currentLiquidityRate and currentVariableBorrowRate. Calling updateState before updateInterestRates means that at time t1, no matter how many deposits or borrows happen in that block, liquidityIndex and variableBorrowIndex change only once, while currentLiquidityRate, currentVariableBorrowRate, and currentStableBorrowRate can still change as balances change.

Within a single block, liquidityIndex and variableBorrowIndex update only once. In that sense, AAVE is less efficient than Compound, because multiple calls within the same block still perform some extra work.

Another way to think about it: after you deposit, your interest effectively starts accruing from the next block. The same is true for borrowing.

averageStableBorrowRate

averageStableBorrowRate is important for stable-rate debt because it affects utilization and therefore also influences the deposit-side rate.

It is defined in StableDebtToken.sol, and it changes inside both mint and burn.

mint:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    // _calculateBalanceIncrease returns: principal debt, total debt including interest, and interest increment
    (, uint256 currentBalance, uint256 balanceIncrease) = _calculateBalanceIncrease(onBehalfOf);

    // user's new average stable borrowing rate = weighted average
    vars.newStableRate = _usersStableRate[onBehalfOf]
      .rayMul(currentBalance.wadToRay())
      .add(vars.amountInRay.rayMul(rate))  // rate is reserve.currentStableBorrowRate
      .rayDiv(currentBalance.add(amount).wadToRay());

    require(vars.newStableRate <= type(uint128).max, Errors.SDT_STABLE_DEBT_OVERFLOW);
    // each user has a _usersStableRate storing the rate at which their stable debt was borrowed
    _usersStableRate[onBehalfOf] = vars.newStableRate;

    //solium-disable-next-line
    _totalSupplyTimestamp = _timestamps[onBehalfOf] = uint40(block.timestamp);

    // protocol-wide average stable borrow rate for this token, updated using the same weighted logic
    vars.currentAvgStableRate = _avgStableRate = vars
      .currentAvgStableRate
      .rayMul(vars.previousSupply.wadToRay())
      .add(rate.rayMul(vars.amountInRay))
      .rayDiv(vars.nextSupply.wadToRay());

burn:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
    if (previousSupply <= amount) {
      // when all stable debt is repaid, reset to zero
      // due to possible rounding errors, we should not simply use previousSupply - amount
      _avgStableRate = 0;
      _totalSupply = 0;
    } else {
      nextSupply = _totalSupply = previousSupply.sub(amount);
      uint256 firstTerm = _avgStableRate.rayMul(previousSupply.wadToRay());
      uint256 secondTerm = userStableRate.rayMul(amount.wadToRay());

      // for the same reason described above, when the last user repays it might happen that
      // user rate * user balance > avg rate * total supply. In that case, just reset to zero
      if (secondTerm >= firstTerm) {
        newAvgStableRate = _avgStableRate = _totalSupply = 0;
      } else {
        newAvgStableRate = _avgStableRate = firstTerm.sub(secondTerm).rayDiv(nextSupply.wadToRay());
      }
    }

Because stable-rate user interest and protocol-level interest are tracked through separate paths, my impression is that these two data series can drift apart in edge cases.