Platform: Code4rena
Start Date: 04/03/2024
Pot Size: $88,500 USDC
Total HM: 31
Participants: 105
Period: 11 days
Judge: ronnyx2017
Total Solo HM: 7
Id: 342
League: ETH
Rank: 17/105
Findings: 4
Award: $760.12
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: Limbooo
Also found by: 0xDemon, 0xspryon, 14si2o_Flint, Aymen0909, Silvermist, alix40, btk, crypticdefense, erosjohn, falconhoof, jnforja, shaka, wangxx2026, y0ng0p3
18.5042 USDC - $18.50
The following functions are in breach of ERC4526 standard.
Both functions do not factor in all global and user-specific limits and thus return a value that will cause a revert.
/// @inheritdoc IERC4626 function maxDeposit(address) external view override returns (uint256) { (, uint256 lendExchangeRateX96) = _calculateGlobalInterest(); uint256 value = _convertToAssets(totalSupply(), lendExchangeRateX96, Math.Rounding.Up); if (value >= globalLendLimit) { return 0; } else { return globalLendLimit - value; } } /// @inheritdoc IERC4626 function maxMint(address) external view override returns (uint256) { (, uint256 lendExchangeRateX96) = _calculateGlobalInterest(); uint256 value = _convertToAssets(totalSupply(), lendExchangeRateX96, Math.Rounding.Up); if (value >= globalLendLimit) { return 0; } else { return _convertToShares(globalLendLimit - value, lendExchangeRateX96, Math.Rounding.Down); } }
Both functions check for the global globalLendLimit
limit and correctly return 0. However, they do not take into account the other global limit MAX_DAILY_LEND_INCREASE_X32
of 10%.
In the deposit function we have the below check which reverts if the amount exceeds the daily lend limit.
if (assets > dailyLendIncreaseLimitLeft) { revert DailyLendIncreaseLimit(); } else { dailyLendIncreaseLimitLeft -= assets; }
Both maxDeposit and maxMint return an amount that assumes it can deposit an amount up to the global lend limit, which will 100% cause a revert since it exceeds the daily lend limit.
Manual Review
Since the daily lend limit is always smaller then the global lend limit, it suffices to replace the limit in both functions.
NOTE: this is an edge case, but the use of setLimits
by an emergencyAdmin in case of emergency can also lead to a "paused" state by setting the minLoanSize
> dailyLendLimit
. This should perhaps also be taken into account when mitigating this issue.
/// @inheritdoc IERC4626 function maxDeposit(address) external view override returns (uint256) { (, uint256 lendExchangeRateX96) = _calculateGlobalInterest(); uint256 value = _convertToAssets(totalSupply(), lendExchangeRateX96, Math.Rounding.Up); - if (value >= globalLendLimit) { + if (value >= dailyLendIncreaseLimitLeft) { return 0; } else { return globalLendLimit - value; } } /// @inheritdoc IERC4626 function maxMint(address) external view override returns (uint256) { (, uint256 lendExchangeRateX96) = _calculateGlobalInterest(); uint256 value = _convertToAssets(totalSupply(), lendExchangeRateX96, Math.Rounding.Up); - if (value >= globalLendLimit) { + if (value >= dailyLendIncreaseLimitLeft) { return 0; } else { return _convertToShares(globalLendLimit - value, lendExchangeRateX96, Math.Rounding.Down); } }
ERC4626
#0 - c4-pre-sort
2024-03-21T14:33:25Z
0xEVom marked the issue as duplicate of #249
#1 - c4-pre-sort
2024-03-24T09:45:50Z
0xEVom marked the issue as sufficient quality report
#2 - c4-judge
2024-04-01T01:34:18Z
jhsagd76 marked the issue as satisfactory
🌟 Selected for report: b0g0
Also found by: 0x175, 0xAlix2, 0xblackskull, 0xspryon, 14si2o_Flint, Fitro, Giorgio, MSaptarshi, MohammedRizwan, Silvermist, boredpukar, crypticdefense, grearlake, kfx, maxim371, y0ng0p3
13.2251 USDC - $13.23
https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Oracle.sol#L395-L423 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/V3Oracle.sol#L359-L374 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/transformers/AutoCompound.sol#L126-L153 https://github.com/code-423n4/2024-03-revert-lend/blob/435b054f9ad2404173f36f0f74a5096c894b12b7/src/automators/Automator.sol#L139-L162
The use of slot0 to obtain sqrtPriceX96 is heavily discouraged as it can easily manipulated. Attackers can acquire huge funds through flash loans and shift the slot0 by doing large swaps on Uniswap.
There are 4 instances of this throughout the protocol. In two of those, the sqrtPriceX96 is simply stored but not used. However, in the 3rd instance it is used to check price difference with chainlink feed and in 4th instance to calculate slippage protection. The last 2 instances are cases which could be subject to price manipulation.
In V3Oracle:_initalizeState
, the sqrtPriceX96 is set in the PositionState struct but is never accessed.
function _initializeState(uint256 tokenId) internal view returns (PositionState memory state) { ( , , address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1 ) = nonfungiblePositionManager.positions(tokenId); state.tokenId = tokenId; state.token0 = token0; state.token1 = token1; state.fee = fee; state.tickLower = tickLower; state.tickUpper = tickUpper; state.liquidity = liquidity; state.feeGrowthInside0LastX128 = feeGrowthInside0LastX128; state.feeGrowthInside1LastX128 = feeGrowthInside1LastX128; state.tokensOwed0 = tokensOwed0; state.tokensOwed1 = tokensOwed1; state.pool = _getPool(token0, token1, fee); (state.sqrtPriceX96, state.tick,,,,,) = state.pool.slot0(); }
In AutoCompound:execute
the slot0 sqrtPriceX96 is set in the ExecuteParams struct, but is never used.
// if a swap is requested - check TWAP oracle if (amountIn > 0) { IUniswapV3Pool pool = _getPool(state.token0, state.token1, state.fee); (state.sqrtPriceX96, state.tick,,,,,) = pool.slot0(); // how many seconds are needed for TWAP protection uint32 tSecs = TWAPSeconds; if (tSecs > 0) { if (!_hasMaxTWAPTickDifference(pool, tSecs, state.tick, maxTWAPTickDifference)) { // if there is no valid TWAP - disable swap amountIn = 0; } } // if still needed - do swap if (amountIn > 0) { // no slippage check done - because protected by TWAP check (state.amountInDelta, state.amountOutDelta) = _poolSwap( Swapper.PoolSwapParams( pool, IERC20(state.token0), IERC20(state.token1), state.fee, params.swap0To1, amountIn, 0 ) ); state.amount0 = params.swap0To1 ? state.amount0 - state.amountInDelta : state.amount0 + state.amountOutDelta; state.amount1 = params.swap0To1 ? state.amount1 + state.amountOutDelta : state.amount1 - state.amountInDelta; } }
In V3Oracle:_getReferencePoolPriceX96
, slot0 is used whenever twapSeconds == 0. Which is the case every time _checkPoolPriceX96
is called. Since the explicit goal of this function is to check against price manipulation attacks, it is ill-advised to use an input which very weak to price manipulation.
function _getReferencePoolPriceX96(IUniswapV3Pool pool, uint32 twapSeconds) internal view returns (uint256) { uint160 sqrtPriceX96; // if twap seconds set to 0 just use pool price if (twapSeconds == 0) { (sqrtPriceX96,,,,,,) = pool.slot0(); } else { uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = 0; // from (before) secondsAgos[1] = twapSeconds; // from (before) (int56[] memory tickCumulatives,) = pool.observe(secondsAgos); // pool observe may fail when there is not enough history available (only use pool with enough history!) int24 tick = int24((tickCumulatives[0] - tickCumulatives[1]) / int56(uint56(twapSeconds))); sqrtPriceX96 = TickMath.getSqrtRatioAtTick(tick); } return FullMath.mulDiv(sqrtPriceX96, sqrtPriceX96, Q96); } function _checkPoolPrice(address token0, address token1, uint24 fee, uint256 derivedPoolPriceX96) internal view { IUniswapV3Pool pool = _getPool(token0, token1, fee); uint256 priceX96 = _getReferencePoolPriceX96(pool, 0); _requireMaxDifference(priceX96, derivedPoolPriceX96, maxPoolPriceDifference); }
In Automator:_validateSwap
slot0 sqrtPriceX96 is used to determine the amountOutMin so this can be clearly influences by manipulating slot0 price. It is also returned from the function, but the sqrtPriceX96 output is never used whenever _validateSwap is called.
function _validateSwap( bool swap0For1, uint256 amountIn, IUniswapV3Pool pool, uint32 twapPeriod, uint16 maxTickDifference, uint64 maxPriceDifferenceX64 ) internal view returns (uint256 amountOutMin, int24 currentTick, uint160 sqrtPriceX96, uint256 priceX96) { // get current price and tick (sqrtPriceX96, currentTick,,,,,) = pool.slot0(); // check if current tick not too far from TWAP if (!_hasMaxTWAPTickDifference(pool, twapPeriod, currentTick, maxTickDifference)) { revert TWAPCheckFailed(); } // calculate min output price price and percentage priceX96 = FullMath.mulDiv(sqrtPriceX96, sqrtPriceX96, Q96); if (swap0For1) { amountOutMin = FullMath.mulDiv(amountIn * (Q64 - maxPriceDifferenceX64), priceX96, Q96 * Q64); } else { amountOutMin = FullMath.mulDiv(amountIn * (Q64 - maxPriceDifferenceX64), Q96, priceX96 * Q64); } }
In the first 2 instances the variable is set but never used, so there is no impact. This alone would be a Low finding since it is redundant code with a price input source that can be manipulated.
In the 3rd and 4th case, there a clear attack vector where price manipulation can either be used to influence a price manipulation check or manipulate the amountOutMin of a swap.
Manual Review
Replace slot.0 with the Uniswap TWAP as source of price information.
Uniswap
#0 - c4-pre-sort
2024-03-22T08:05:47Z
0xEVom marked the issue as duplicate of #132
#1 - c4-pre-sort
2024-03-22T08:17:55Z
0xEVom marked the issue as duplicate of #175
#2 - c4-pre-sort
2024-03-24T09:51:35Z
0xEVom marked the issue as sufficient quality report
#3 - c4-judge
2024-04-01T10:41:38Z
jhsagd76 marked the issue as satisfactory
🌟 Selected for report: Bauchibred
Also found by: 0x11singh99, 0x175, 0xAlix2, 0xDemon, 0xGreyWolf, 0xPhantom, 0xspryon, 14si2o_Flint, Arabadzhiev, Aymen0909, Bigsam, BowTiedOriole, CRYP70, DanielArmstrong, FastChecker, JecikPo, KupiaSec, MohammedRizwan, Norah, Timenov, Topmark, VAD37, adeolu, btk, crypticdefense, cryptphi, givn, grearlake, jnforja, kennedy1030, kfx, ktg, lanrebayode77, n1punp, santiellena, stonejiajia, t4sk, thank_you, tpiliposian, wangxx2026, y0ng0p3, zaevlad
42.7786 USDC - $42.78
In the _updateAndCheckCollateral
function, there are 2 checks to make sure that the collateralValueLimitFactorX32
does not exceed type(uint32).max
. This is because this maximal value represents a collateral value limit of 100%.
However in the actual checks, <
is used instead of <=
. Meaning a valid value of 100% (type(uint32).max) would cause a revert.
Even though setting a value of 100% is very unlikely, it remains a valid input and thus should not cause a revert.
if ( - collateralValueLimitFactorX32 < type(uint32).max + collateralValueLimitFactorX32 <= type(uint32).max && _convertToAssets(tokenConfigs[token0].totalDebtShares, debtExchangeRateX96, Math.Rounding.Up) > lentAssets * collateralValueLimitFactorX32 / Q32 ) { revert CollateralValueLimit(); } collateralValueLimitFactorX32 = tokenConfigs[token1].collateralValueLimitFactorX32; if ( - collateralValueLimitFactorX32 < type(uint32).max + collateralValueLimitFactorX32 <= type(uint32).max && _convertToAssets(tokenConfigs[token1].totalDebtShares, debtExchangeRateX96, Math.Rounding.Up) > lentAssets * collateralValueLimitFactorX32 / Q32 ) { revert CollateralValueLimit(); }
In the Vault
liquidate
function, the internal function _sendPositionValue
is called which calls nonfungiblePositionManager.decreaseLiquidity
with 100% slippage tolerance.
Meaning MEV bots will take advantage and the liquidity returned will be either extremely small or non-existing.
This should a High finding, but it is mitigated by another finding. The _sendPositionValue
does not return the decreased liquidity + fees, but only the fees.
Which means in 90%+ of the cases, the amount0
and amount1
returned will be smaller than params.amount0Min
and params.amount1Min
and thus cause a SlippageError
revert.
function liquidate(){ // send promised collateral tokens to liquidator (amount0, amount1) = _sendPositionValue(params.tokenId, state.liquidationValue, state.fullValue, state.feeValue, msg.sender); if (amount0 < params.amount0Min || amount1 < params.amount1Min) { revert SlippageError(); } } function _sendPositionValue( uint256 tokenId, uint256 liquidationValue, uint256 fullValue, uint256 feeValue, address recipient ) internal returns (uint256 amount0, uint256 amount1) { uint128 liquidity; uint128 fees0; uint128 fees1; // if full position is liquidated - no analysis needed if (liquidationValue == fullValue) { (,,,,,,, liquidity,,,,) = nonfungiblePositionManager.positions(tokenId); fees0 = type(uint128).max; fees1 = type(uint128).max; } else { (,,, liquidity,,, fees0, fees1) = oracle.getPositionBreakdown(tokenId); // only take needed fees if (liquidationValue < feeValue) { liquidity = 0; fees0 = uint128(liquidationValue * fees0 / feeValue); fees1 = uint128(liquidationValue * fees1 / feeValue); } else { // take all fees and needed liquidity fees0 = type(uint128).max; fees1 = type(uint128).max; liquidity = uint128((liquidationValue - feeValue) * liquidity / (fullValue - feeValue)); } } if (liquidity > 0) { nonfungiblePositionManager.decreaseLiquidity( INonfungiblePositionManager.DecreaseLiquidityParams(tokenId, liquidity, 0, 0, block.timestamp) ); } (amount0, amount1) = nonfungiblePositionManager.collect( INonfungiblePositionManager.CollectParams(tokenId, recipient, fees0, fees1) ); }
The vaultInfo
function calculates the total lent by Rounding Up _convertToAssets
.
function vaultInfo() external view override returns ( uint256 debt, uint256 lent, uint256 balance, uint256 available, uint256 reserves, uint256 debtExchangeRateX96, uint256 lendExchangeRateX96 ) { (debtExchangeRateX96, lendExchangeRateX96) = _calculateGlobalInterest(); (balance, available, reserves) = _getAvailableBalance(debtExchangeRateX96, lendExchangeRateX96); debt = _convertToAssets(debtSharesTotal, debtExchangeRateX96, Math.Rounding.Up); lent = _convertToAssets(totalSupply(), lendExchangeRateX96, Math.Rounding.Up); }
Yet the lendInfo
function calculates the lend by Rounding Down _convertTaAssets
.
function lendInfo(address account) external view override returns (uint256 amount) { (, uint256 newLendExchangeRateX96) = _calculateGlobalInterest(); amount = _convertToAssets(balanceOf(account), newLendExchangeRateX96, Math.Rounding.Down); }
As a result, the sum of all loans requested through lendInfo
will be smaller than vaultInfo
. The difference is minute, but I recommend changing the rounding of vaultInfo
in order to be consistent.
_sendPositionValue
function, a check is performed to see whether the fees cover the liquidation value. If not, liquidity is calculated and decreased. However, in the check <
is used instead of <=
, meaning liquidity will be calculated when fees == liquidationValue.This is unnecessary so I recommend it being changed.
// only take needed fees - if (liquidationValue < feeValue) { + if (liquidationValue <= feeValue) { liquidity = 0; fees0 = uint128(liquidationValue * fees0 / feeValue); fees1 = uint128(liquidationValue * fees1 / feeValue);
The _requireMaxDifference
function checks for price manipulation by comparing a calculated difference with a maxDifference and reverting when it exceeds.
Yet in the if statement we see that >=
is used instead of >
, meaning a price difference equal to the maxDifference will cause a revert. Since the maxDifference should be an valid value, I recommend the following change:
function _requireMaxDifference(uint256 priceX96, uint256 verifyPriceX96, uint256 maxDifferenceX10000) internal pure { uint256 differenceX10000 = priceX96 > verifyPriceX96 ? (priceX96 - verifyPriceX96) * 10000 / priceX96 : (verifyPriceX96 - priceX96) * 10000 / verifyPriceX96; // if too big difference - revert - if (differenceX10000 >= maxDifferenceX10000) { + if (differenceX10000 > maxDifferenceX10000) { revert PriceDifferenceExceeded(); } }
onERC721Received is missing the "operator" named input param. This has no actual effect but is not in line with the EIP721 standard.
function onERC721Received(address, address from, uint256 tokenId, bytes calldata data) external override returns (bytes4)
The _sendPositionValue
function is supposed to return the total amount of collateral tokens transferred to the liquidator, but it only returns the fees.
function liquidate(){ // send promised collateral tokens to liquidator (amount0, amount1) = _sendPositionValue(params.tokenId, state.liquidationValue, state.fullValue, state.feeValue, msg.sender); if (amount0 < params.amount0Min || amount1 < params.amount1Min) { revert SlippageError(); } } function _sendPositionValue( uint256 tokenId, uint256 liquidationValue, uint256 fullValue, uint256 feeValue, address recipient ) internal returns (uint256 amount0, uint256 amount1) { uint128 liquidity; uint128 fees0; uint128 fees1; // if full position is liquidated - no analysis needed if (liquidationValue == fullValue) { (,,,,,,, liquidity,,,,) = nonfungiblePositionManager.positions(tokenId); fees0 = type(uint128).max; fees1 = type(uint128).max; } else { (,,, liquidity,,, fees0, fees1) = oracle.getPositionBreakdown(tokenId); // only take needed fees if (liquidationValue < feeValue) { liquidity = 0; fees0 = uint128(liquidationValue * fees0 / feeValue); fees1 = uint128(liquidationValue * fees1 / feeValue); } else { // take all fees and needed liquidity fees0 = type(uint128).max; fees1 = type(uint128).max; liquidity = uint128((liquidationValue - feeValue) * liquidity / (fullValue - feeValue)); } } if (liquidity > 0) { nonfungiblePositionManager.decreaseLiquidity( INonfungiblePositionManager.DecreaseLiquidityParams(tokenId, liquidity, 0, 0, block.timestamp) ); } (amount0, amount1) = nonfungiblePositionManager.collect( INonfungiblePositionManager.CollectParams(tokenId, recipient, fees0, fees1) ); }
There are several instances where sqrtPriceX96 is set in a struct or returned from a function but not used. This should be cleaned up since unforeseen side-effect could lead to serious security vulnerabilities.
V3Oracle:_initalizeState
, the sqrtPriceX96 is set in the PositionState struct but is never accessed.AutoCompound:execute
the slot0 sqrtPriceX96 is set in the ExecuteParams struct, but is never used.Automator:_validateSwap
slot0 sqrtPriceX96 is returned from the function, but the sqrtPriceX96 output is never used whenever _validateSwap is called.The _requireMaxDifference
states Natspec & naming this returns information about one loan.
But function code clearly takes all loans against one collateral position, and returns variables based on the sum of loans on a a position. Thereby giving a user erroneous understanding of their actual situation.
I recommend changing the natspec and naming to correctly reflect the code.
withdraw
and redeem
are the only functions to not have a permit2 version. Even though the use-case is rare, for completeness those version should be added.In two place in V3Vault
, code comments are made suggesting the "vault itself" can perform an action. These are outdated comments dating from a change in logic dating several weeks ago and should be removed for clarity.
// only the owner of the loan, the vault itself or any approved caller can call this if (loanOwner != msg.sender && !transformApprovals[loanOwner][tokenId][msg.sender]) { revert Unauthorized(); } // if not in transfer mode - must be called from owner or the vault itself if (!isTransformMode && tokenOwner[tokenId] != msg.sender && address(this) != msg.sender) { revert Unauthorized(); }
#0 - c4-pre-sort
2024-03-24T21:25:54Z
0xEVom marked the issue as sufficient quality report
#2 - c4-judge
2024-04-01T11:32:25Z
jhsagd76 marked the issue as grade-a
#3 - kalinbas
2024-04-01T21:29:34Z
We confirm the issues which are valid: L-03, L-04, L-05
#4 - c4-sponsor
2024-04-01T21:29:39Z
kalinbas (sponsor) confirmed
🌟 Selected for report: 14si2o_Flint
Also found by: Bauchibred, K42, Sathish9098, hunter_w3b, invitedtea, popeye, yongskiws
685.61 USDC - $685.61
Revert have been developing analytical tools and dashboards to simplify and facility interaction with AMM protocols since June 2022. It is their stated belief that AMM protocols will play a pivotal role in the financial crypto markets in the coming years and they wish to help retails invests interact with these complex and sometimes obtuse protocols. These tools are mainly focused on Uniswap v3.
Revert Lend is their newest initiative, an ERC4626 lending protocol that takes a unique approach by allowing the Uniswap v3 NFT position to be used as collateral to facilitate the acquisition of ERC20 token loans, while allowing the users to retain control and management of their capital within Uniswap v3 Pools. As a consequence, it becomes possible to unify all the liquidity provided from all accepted tokens into one pool.
Another rare aspect of the protocol is the variable interest rate, which is completely dependent on the ebb and flow of the market.
Day 1: 7h35
Day 2-3: 13h37
Day 4-6 : 17h50
Day 7-8: 11h06
Day 9-11: 20h55
In order to properly understand the architecture of the protocol, it is neccessary to understand its history.
Revert started back in June of 2022 with a mission of building actionable analytics for liquidity providers in AMM protcols, with a focus on Uniswap. To this end they developed the initiator and over time they added new functionalities and products such as V3Utils, Auto-Compound, Auto-Exist, Leverage-Transformer and Auto-Range. Revert Lend is their newest product which aims to introduce an ERC4626 vault.
As such, the architure should not be seen as a master drawing of "the Architect", but as many layers of architectural drawings, each becoming more complex and integrating what came before. As ingenious as it may be, the more custom functionality that has to be developed to allow everthing to fit together, the higher the chances of something being overlooked and exploited.
This becomes apparant when we evaluate how the V3Vault interacts with the automated contracts through tranform()
.
For AutoCompound
and AutoRange
:
executeWithVault
from the transformerIvault(vault).transform()
execute()
to the transformerFor AutoExit
,FlashloanLiquidator
,LeverageTransformer
and V3Utils
:
transform()
from the vault.I could find no explanation anywhere in the documentation for having 2 different patterns. In itself this is not a security risk, but the chance of their being an oversight and a risk today or in future incarnations of the protocol, increases exponentially with each additional pattern.
Another issue is the multiplication of similar functionalities across the different contracts. When we look at Uniswap decreaseLiquidity
, we can see that every contract besides FlashloanLiquidator
can call this on a position. Again, in itself not a security risk, but the effort required to make sure that all instances are correctly configured and there exists no difference that be exploited, increases exponentially with each added similar functionality.
Streamline and perhaps externalise (ITransform) the access pattern between the vault and transformers so that there is only one correct way in which the communication between the two can happen.
Re-factor some of the existing contracts to reduce multiplication of similar functionality.
For each contract we will give a short description, an overview of the most important functions available to users and a custom function diagram to visualise internal and external calls. Furthermore, any findings will be mentioned with the relevant function.
Please refer to below Legend to understand the diagrams.
An IERC4626 compliant contract that manages one ERC20 asset for lending and borrowing. It accepts Uniswap v3 positions as collateral where these positions are composed of any 2 tokens which each have been configured to have a collateralFactor > 0.
vaultInfo
and lendInfo
, we can see that the rounding for calculating the lending information is different. Due to this, the total lent from vaultInfo
will be greater then the sum of lendInfo
from all accounts. This could be considered a breaking of a fundamental invariant, as detailed in finding [L-03] Inconsistent Lending rounding in view functions
.[NC-01] Incorrect naming and Natspec of loanInfo
.balanceOf(address(this))
, which opens an important attack vector of inflation attacks as detailed in finding H- Inflation attack due to the absence of dead shares and the reliance on balanceOf
.withdraw
and redeem
are the only functions to not have a permit2 version. Even though the use-case is rare, for completeness those version should be added. More information in finding [NC-03] lacking permit2 functions
onERC721Received
.onERC721Received
is inconsistent with the EIP721 standard, as detailin finding [L-06] onERC721Received not compliant with EIP721
.onERC721Received
transform
function.approveTransform
, which allows them to call transform to perform any user actions in an automated fashion.[NC-04] Incorrect code comments
._repay()
liquidate
function has a major flaw in that it only transfers fees from the owner to the liquidator and not decreased liquidity. This causes the liquidator to not obtain the liquidity he paid for and the owner receives part of the liquidity he should have lost. More details can be found in finding H-Liquidation only transfers fees to liquidator, while part or all of the decreased liquidity is transferred back to the defaulting owner
.[L-02] No slippage protection in Vault:liquidate
This is contract is in V3Vault to calculate the value of positions. It is the main vector of obtaining price data and uses both Chainlink aswell as Uniswap v3 TWAP. Futhermore, it also provides emergency fallback mode.
getValue
_requireMaxDifference
, which is called by _checkPoolPrice
, as detailed in finding [L-05] Limit set to low in _requireMaxDifference
.getPositionBreakDown
Stateless contract with utility functions for Uniswap V3 positions.
executeWithPermit
execute
with EIP712 permit.execute
_decreaseLiquidity()
_collectFees()
_swapAndIncrease()
_swapAndMint()
_routerSwap()
_transferToken()
swap
This automator contract allows a v3 position to be automatically removed (limit order) or to be swapped to the opposite token (stop loss order) when it reaches a certain tick. The execution of the optimized swap is delegated to a revert controlled bot (operator) using an externalswap router.
execute
liquidate
leverageUp
transform
function in V3Vault.sol
leverageDown
transform
function in V3Vault.sol
This contract allows an approved operator of AutoCompound to compound a position. When called from outside the vault, the positions need to be approved, when called inside, the owner needs to approve the position to be transformed by the contract.
executeWithVault
execute
transform
.This contract allows an approved operator of AutoRange to change the range for the configured position. If called inside Vault, it will use the transform method. If outside, the positions need to be approved for the contract and configures with configToken function.
executeWithVault
execute
transform
.On the whole, I evaluate the quality of Revert Lend
codebase to be "Good". The contract and function design are clearly well though out, access control is properly implemented and the various standards are well implemented. Some improvements on attention on detail would be advisable and there are architectural complexities. Details are explained below:
Codebase Quality Categories | Comments |
---|---|
Architecture | Each of the seperate products of Revert is well designed, segregating functionality into distinct contracts (e.g., automators, interfaces, transformers, utils) for clarity and ease of maintenance. Further seperating the interest model from the vault indicates a clear intention for seperation of concerns. When we take entire complex puzzle of all the products together, there are certain concerns as explained above in the Architecture part. |
Upgradeability | In the whitepaper (page 5), it is stated:Revert Lend implements a nonupgradable contract design. This decision ensures the integrity of the protocol, minimizing the risk of introducing errors or modifying security trade-offs, through any future modifications. This represents, in my humble opinion, a grave error in judgement. The primary reason why most, if not all, major protocols implement some form of upgradeability, is that bugs and errors are almost assured to happen no matter the quality of the codebase. Regardless of the number of audits, there cannot be a guarantee that a bug will not be found. If the bug stops users from obtaining their funds or allow malicious users to steal with impunity, the protocol team is powerless to act without some form of control. I would strongly recommend the team to implement upgradeability which can easily be burned after a certain amount of time has passed and the risk of problems becomes minute. |
Code Comments | The contracts are accompanied by comprehensive comments, facilitating an understanding of the functional logic and critical operations within the code. Functions are described purposefully, and complex sections are elucidated with comments to guide readers through the logic. However, there are several instances where comments remain when the code logic has changed. The protocol could benefit from a spring cleaning excercise where the code comments are all reviewed. |
Testing | The protocol has an excellent level of test coverage, approaching nearly 100%. This ensures that a wide array of functionalities and edge cases are tested, contributing to the reliability and security of the code. However, to further enhance the testing framework, the incorporation of fuzz testing and invariant testing is recommended. |
Security Practices | The contracts demonstrate awareness of common security pitfalls in Solidity development. Functions are guarded with appropriate access control modifiers (e.g., onlyOwner ,emergencyAdmin , transformer mode checks), and state-changing functions are protected against reentrancy attacks. One area of concern is the intention of the protocol to implement transient storage for the transformedTokenId variable, which guards against reentrancy, once the Dencun upgrade goes live. Transient storage is extremely new and there have already been realistic formulations of reentrancy attacks through the use transient storage. As such I would recommend much caution in implementing these transiant variables and/or request an additional audit to explore these specific security issues. |
Error Handling | The custom errors are defined in IErrors and correctly applied throughout the codebase. However, in some cases it would have usefull to implement the errors with a more expansive message. |
Documentation | The sole documentation for the V3Vault is the whitepaper, which is excellently written. Nevertheless, more documentation describing the protocol would be very usefull. |
The protocol defines 2 privileged roles: Owner
and EmergencyAdmin
.
The Owner
is set to a Multisig and Timelock according to the contest README and has the following rights:
V3Oracle
:
V3Vault
:
AutoCompound
:
The main issue with the owner design is that all changes are One-Step operations. Regardless whether it is changing the owner itself or setting a token configuration, even the tiniest mistake could case major damage to the protocol. Either by setting the owner to a random address or wrongly setting twap seconds or a token address, which would give hackers an immediate and easy attack vector to drain the protocol, a single mistake is devastating.
I would recommend the implementation of a two-step approval process for the most critical of operations. Also, the contest README states that the owner is a multisig subject to a Timelock, but nothing of the sorts can be seen in the contract code.
The EmergencyAdmin
is set to a Multisig according to the contest README and has the following rights:
V3Oracle
:
V3Vault
:
The emergencyAdmin can essentially pause the protocol by setting the limits to 0
and it can change the oracle from TWAP to Chainlink or change the verification mode. However, if issues are found that are not affected by these limits (liquidation, transformer misbehaving), then the emergencyAdmin does not have the powers to take action.
Instead of a custom role, I would recommend to implement the standardised pattern of pause/unpause, and adding the whenNotPaused
modifier to all functions which change state in the protocol. Thus allowing the admin to freeze the entire protocol in case of emergency.
transformedTokenId
variable, which is critical for guarding against reentrancy. This could a problem since it is a new and fairly unexplored functionality. Since some theoretical reentrancy attacks are already being discussed, I would suggest much prudence in implementing this.71 hours
#0 - c4-pre-sort
2024-03-24T08:57:27Z
0xEVom marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-25T20:31:35Z
0xEVom marked the issue as high quality report
#2 - 0xEVom
2024-03-25T20:38:52Z
Marking this as best despite the broken diagram embeds as it shows the least signs of LLM usage and provides solid, non-generic advice relevant to the protocol.
#3 - c4-sponsor
2024-03-26T16:44:08Z
kalinbas (sponsor) acknowledged
#4 - c4-judge
2024-04-01T01:31:03Z
jhsagd76 marked the issue as grade-a
#5 - c4-judge
2024-04-01T15:40:10Z
jhsagd76 marked the issue as selected for report