Historically Aave has used half-up, or so called bankers rounding, for rounding operations. This has been a source of confusion and bugs in the past as it makes the rounding somewhat chaotic.
The aave protocol handles balances & yield by storing a scaledBalance and an index. The actual balance is then derived by multiplying the scaledBalance by the index or by dividing the amount by the index.
The index has 1e27 precision, which means that the rounding error usually applies on the least significant digit of the asset.
In practice that means that when e.g. depositing 1e18 of an asset, your balance might be 1e18 +-1. The rounding depends on time & index accrual, so it's difficult to exactly predict the outcome. The stata tokens and similar systems built on top therefore use a custom rounding function to ensure that the rounding is always in favor of the protocol (like it is done on the 4626 spec).
It's important to note that the rounding error is still technically unbounded. The index can grow almost(uint128.max) infinitely and the shares and the underlying share the same decimal precision. The intention of this change is to always round in favor of the protocol, to avoid insolvency situations.
This upgrade aligns the rounding methods by:
When users supply(amount) to the protocol, the amount is rounded down. This means that from now on a supply will always be less than or equal to the amount the user supplied. A supply(n) will always yield m = n / index scaled balance.
Example: supply(1e18) would previously have resulted in an aToken balance equal to scaledAmount = halfUp(amount / index), so that amount' = halfUp(scaledAmount * index) = amount (+-precision loss). After the change the variance is limited to amount' = halfUp(scaledAmount * index) = amount (-precision loss).
When users withdraw(amount) from the protocol, the amount is rounded up. This is done to ensure that the user always receives at least the amount they requested.
Example: withdraw(amount) would previously have resulted in releasing amount of underlying and burning scaledAmount = halfUp(amount / index) shares, so that amount' = halfUp(scaledAmount * index) = amount (+-precision loss).
balanceOf and totalSupply are now rounded down. Currently these methods sometimes overaccount by 1 wei, and sometimes underaccount by 1 wei. From now on they will no longer overaccount.
This is done to ensure correct behavior in regards to user operations, so that e.g. withdraw(aToken.balanceOf(user)) will always redeem the full user balance.
When users borrow(amount) from the protocol, the amount is rounded up. This is done to ensure that the protocol never underaccounts the user debt.
When users repay(amount) or are being liquidated, the protocol must ensure that the repayment covers the full debt.
balanceOf and totalSupply are now rounded up. Currently these methods sometimes overaccount by 1 wei, and sometimes underaccount by 1 wei. From now on they will no longer underaccount.
This is done to ensure correct behavior in regards to user operations, so that e.g. repay(vToken.balanceOf(user)) will always repay the full debt.
On transfers, the scaled amount is rounded up.
While for other methods the rational is obvious(and well specified on ERC4626), on transfers the "correct" path is more debatable. We think that currently the more "problematic" code-path is, if you pull x funds, but you get x-1. Therefore by rounding up the scaled amount, we ensure that the contract pulling receives at least the amount it requested.
When calculating a user's health factor, the protocol must convert their asset and debt balances into a common base currency (e.g., USD). To ensure the protocol's safety, these conversions are rounded pessimistically.
- Collateral (aToken) Value: The value of a user's supplied assets in the base currency is always rounded down. This ensures the protocol never overestimates the value of a user's collateral.
- Debt (vToken) Value: The value of a user's borrowed assets in the base currency is always rounded up. This ensures the protocol never underestimates a user's debt. A key consequence of this is that any non-zero debt, no matter how small, will be valued as at least 1 wei in the base currency. This prevents situations where tiny "dust" debts could round down to zero after conversion, making them invisible to Health Factor calculations.
This approach of always rounding in favor of the protocol during health factor calculations is a critical security measure that protects it from potential insolvencies arising from rounding discrepancies. As a result of this change, the health factor of a user may be lower than in previous versions of the protocol.
A lot of the precision loss is caused by having multiple conversions from unscaled to scaled and back. To name a few examples:
- when validating caps, for the validation the
scaledAmount * index+input.amountwere considered to be the total supply - this is not accurate though as the conversion frominput.amounttoscaledAmountwill always have a precision loss. - when minting tokens to the treasury, the protocol stores the
scaledAmountinaccruedToTreasury. When the protocol mints to the treasury, theaccruedToTreasuryis scaled up and then scaled down again, which can lead to precision loss.
While these problems are not critical, for the most part, they can be mitigated by consistently working with the scaled values throughout the protocol. Therefore, mint and burn now accept scaledAmount as an additional input, which allows avoiding repeated roundings. As a side effect, this also slightly reduces gas consumption across the board.
In v3.4 we increased the robustness of the flag logic for borrow operations, by switching validations from unscaled to scaled comparisons to checks against the actual balance (e.g. balanceZeroAfterBurn).
In v3.5 we decided to double down on these robustness improvements:
- on
repayWithATokenandwithdraw, the collateral flag is now properly set tofalsewhen burning all aTokens in the process. In previous versions of the protocol, there were edge cases in which the collateral flag was not properly updated.
Aave historically has never accurately tracked allowance. The reason for this is that in practice most operations are performed with the desired amount of assets, but the a/v token converting these amounts to shares.
For allowance / approval, this means that the consumed allowance is not always equal to the amount transferred. While this problem is not perfectly solvable without breaking changes, in v3.5 the protocol ensures that the exact consumed allowance is burned if available.
Example: When a user calls transferFrom(sender, recipient, 100) in most cases the transfer will transfer slightly more than 100 tokens (e.g 101). This is due to precision loss between assets/shares conversion.
In Aave versions < 3.5 this action would always result in burning 100 allowance. On Aave v3.5, the transfer will check the balance difference on the sender and discount up to the difference from the allowance.
Example: The user transfers 100, but due to rounding he loses 102 balance. The allowance is reduced by up to 102. If the original allowance was only 100, the transaction will still pass for backwards compatibility.
- smaller refactoring in
LiquidationLogicmaking the code more consistent repayWithATokenis only allowed when the user is still healthy after the repayment. This is done in order to prevent some edge cases where the user would have debt, but no collateral or now with the adjustments on rounding, bringing himself into liquidation area through selfRepayment.eliminateReserveDeficitreturn value: TheeliminateReserveDeficitfunction has been updated to return theuint256amount of deficit that was actually covered. This returned value represents the lesser of the inputamountand the actualdeficitof the reserve at the time of the call. This provides clarity to the caller on the exact amount that was successfully written off.- Note: This is a non-breaking change. The migration from no return value to a
uint256return value is not expected to break any existing integrations.
- Note: This is a non-breaking change. The migration from no return value to a
- In previous versions of the protocol,
MintandBurnevents onATokenandVariableDebtTokendid not always perfectly reflect the amount minted and burned due to imprecision in the calculation of theamountToMintandamountToBurnvariables. In v3.5, thevalueemitted inMintandBurnevents now always accurately reflects the difference between the previous upscaled balance and the new upscaled balance. ForATokentransfers, theTransferevent emits the input amount, while theBalanceTransferevent emits the precise scaled amount being transferred. Due to the new rounding logic, the actual change in unscaled balance might differ slightly from the input amount. - The control flow of
borrowhas been altered. While in previous versions of the protocol theborrowfunction would first check the hf limitations, from v3.5.0 the healthfactor check is performed at the end. Moving the check allows to de-duplicate the healthfactor related calculations and avoids issues due to non-equivalence in some edge cases.
- General changes:
- Changed conversions between scaled and unscaled amounts:
- In the
ATokencontract:- The
balanceOfandtotalSupplyfunctions now round down (from scaled to unscaled). - The
mintfunction now rounds down (from unscaled to scaled). - The
burnfunction now rounds up (from unscaled to scaled). - Calculations of a transfer's scaled amount now round up in
AToken(from unscaled to scaled).
- The
- In the
VariableDebtTokencontract:- The
balanceOfandtotalSupplyfunctions now round up (from scaled to unscaled). - The
mintfunction now rounds up (from unscaled to scaled). - The
burnfunction now rounds down (from unscaled to scaled).
- The
- In the
- Changed conversions between scaled and unscaled amounts:
Poolcontract:- The
finalizeTransferfunction now accepts scaled amounts instead of unscaled amounts. This avoids the precision loss caused by rounding the unscaled parameters. - The
eliminateReserveDeficitfunction now returns the actual amount of deficit that was covered.
- The
ATokencontract:- The following functions now accept a
scaledAmountparameter instead of theamountparameter, which avoids the precision loss caused by rounding theamountparameter:mintmintToTreasurytransferOnLiquidation
- The
burnfunction now accepts a newscaledAmountargument, which is the scaled amount of tokens to be burned. This avoids the precision loss caused by rounding theamountparameter. - Changed the logic and math of the allowance decrease in the
transferFromfunction.
- The following functions now accept a
VariableDebtTokencontract:- The
mintfunction now accepts a newscaledAmountargument, which is the scaled amount of tokens to be minted. This avoids the precision loss caused by rounding theamountparameter. - The
burnfunction now accepts ascaledAmountparameter instead of theamountparameter, which avoids the precision loss caused by rounding theamountparameter. - Added new state variables
__unusedGapand__DEPRECATED_AND_NEVER_TO_BE_REUSED. - Changed the logic and math of the borrow allowance decrease in the
mintfunction.
- The
DebtTokenBasecontract:- Changed the logic and math of the borrow allowance decrease in the
_decreaseBorrowAllowancefunction.
- Changed the logic and math of the borrow allowance decrease in the
IncentivizedERC20contract:- Added a new
_spendAllowancefunction, which is used to decrease the allowance of a spender.
- Added a new
ScaledBalanceTokenBasecontract:- The following functions now accept scaled amounts instead of unscaled amounts:
_mintScaled_burnScaled
- Changed the math of the
amountToMintvariable in the_mintScaledfunction. - Changed the math of the
amountToBurnandamountToMintvariables in the_burnScaledfunction.
- The following functions now accept scaled amounts instead of unscaled amounts:
- Libraries:
BorrowLogiclibrary:- The
executeBorrowfunction now checks the health factor and the LTV of a user after the borrow operation by calling the newValidationLogic.validateHFAndLtvfunction. - In the
executeRepayfunction, the contract now checks the health factor of a user (user, notonBehalfOf) after the repay operation when the user is repaying withATokens.
- The
FlashLoanLogiclibrary:- The
executeFlashLoanfunction now rounds up the protocol fees for flash loans of type0.
- The
GenericLogiclibrary:- In the
calculateUserAccountDatafunction, the precision in health factor calculations has been improved. - In the
calculateAvailableBorrowsfunction, theavailableBorrowsInBaseCurrencyvariable is now rounded down. - In the
_getUserDebtInBaseCurrencyfunction, the amount in the base currency is now rounded up.
- In the
LiquidationLogiclibrary:- The
executeEliminateDeficitfunction now returns the actual amount of deficit that was covered. - In the
executeLiquidationCallfunction, some rounding behavior has been changed:- The
borrowerReserveDebtInBaseCurrencyvariable is now rounded up. - The
isDebtMoreThanLeftoverThresholdvariable is now rounded up.
- The
- In the
_calculateAvailableCollateralToLiquidatefunction, thedebtAmountNeededvariable is now rounded up whenmaxCollateralToLiquidate > borrowerCollateralBalance.
- The
ReserveLogiclibrary:- In the
_accrueToTreasuryfunction, thetotalDebtAccruedvariable is now rounded down.
- In the
SupplyLogiclibrary:- The
executeFinalizeTransferfunction now accepts scaled amounts instead of unscaled amounts.
- The
ValidationLogiclibrary:- The following functions now accept scaled parameters instead of unscaled parameters, which avoids the precision loss caused by rounding unscaled parameters:
validateSupplyvalidateWithdrawvalidateBorrowvalidateRepay
- The
validateBorrowfunction no longer checks the user's health factor and LTV before the borrow operation.
- The following functions now accept scaled parameters instead of unscaled parameters, which avoids the precision loss caused by rounding unscaled parameters:
- A new
TokenMathlibrary has been added. It contains functions to perform conversions between scaled and unscaled amounts for A/V tokens.