Platform: Code4rena
Start Date: 01/08/2023
Pot Size: $91,500 USDC
Total HM: 14
Participants: 80
Period: 6 days
Judge: gzeon
Total Solo HM: 6
Id: 269
League: ETH
Rank: 8/80
Findings: 2
Award: $1,175.28
π Selected for report: 1
π Solo Findings: 0
π Selected for report: Jeiwan
Also found by: HChang26, Jeiwan, LokiThe5th, osmanozdemir1, said
1084.0891 USDC - $1,084.09
https://github.com/code-423n4/2023-08-goodentry/blob/4b785d455fff04629d8675f21ef1d1632749b252/contracts/GeVault.sol#L226 https://github.com/code-423n4/2023-08-goodentry/blob/4b785d455fff04629d8675f21ef1d1632749b252/contracts/GeVault.sol#L239
When withdrawing funds from a disabled vault, users can burn all their LP tokens and receive 0 asset tokens, basically causing a loss of all their previously deposited tokens. This impacts all users who withdraw after rebalance()
has been called or after a user has withdrawn their LPs earlier.
The GeVault
can be paused by the owner by setting the isEnabled
flag to true
via a call to the GeVault.setEnabled() function. When the vault is paused:
Since assets are not re-deployed during a withdrawal, the calculation of TVL of the vault and the calculation of the token amount returned to the user during a withdrawal is broken:
uint vaultValueX8 = getTVL(); uint valueX8 = vaultValueX8 * liquidity / totalSupply(); amount = valueX8 * 10**ERC20(token).decimals() / oracle.getAssetPrice(token); uint fee = amount * getAdjustedBaseFee(token == address(token1)) / 1e4;
getTVL()
function calls getTickBalance()
on each tick (GeVault.sol#L392):
for(uint k=0; k<ticks.length; k++){ TokenisableRange t = ticks[k]; uint bal = getTickBalance(k); valueX8 += bal * t.latestAnswer() / 1e18; }
getTickBalance()
function gets the aToken balance of the vault (GeVault.sol#L420):
address aTokenAddress = lendingPool.getReserveData(address(t)).aTokenAddress; liquidity = ERC20(aTokenAddress).balanceOf(address(this));
removeFromTick()
function (which is called in the withdraw
function) has already redeemed all aTokens, as well as LP tokens:
lendingPool.withdraw(address(tr), aBal, address(this)); tr.withdraw(aBal, 0, 0);
getTVL()
function calls the TokenisableRange.latestAnswer() function to get the price of 1 LP token:
TokenisableRange t = ticks[k]; uint bal = getTickBalance(k); valueX8 += bal * t.latestAnswer() / 1e18;
latestAnswer()
function computes the value of the total liquidity deposited to the underlying pool and divides it by the number of LP tokens issued, to compute per-LP-token value (TokenisableRange.sol#L338).latestAnswer()
will also return 0.As a result, the getTVL()
call will return 0, which will result in 0 token amounts returned to the user. However, all user's LP tokens will be burned.
The issue doesn't impact the first user who withdraws from a paused vaultβit only impact those who withdraw after them. The first withdrawal will correctly compute the vault's TVL, however it'll also withdraw all liquidity from ticks and won't re-deploy it, setting TVL to 0 for all other users.
Manual review
In the GeVault.getTVL()
function, consider counting current vault's token balances as part of its TVL. Since funds are not re-deployed when the vault is paused, they'll remain in the contract. But since they're removed from Uniswap pools and the lending pool, the current implementation doesn't count them as part of the TVL.
Other
#0 - c4-pre-sort
2023-08-09T07:47:37Z
141345 marked the issue as primary issue
#1 - c4-pre-sort
2023-08-09T07:58:51Z
141345 marked the issue as duplicate of #223
#2 - c4-judge
2023-08-20T17:33:59Z
gzeon-c4 marked the issue as satisfactory
π Selected for report: Jeiwan
Also found by: HChang26, Jeiwan, LokiThe5th, osmanozdemir1, said
1084.0891 USDC - $1,084.09
Users can lose a portion of their deposited funds if some of their funds haven't been deposited to the underlying Uniswap pools. There's always a chance of such event since Uniswap pools take balanced token amounts when liquidity is added but GeVault
doesn't pre-compute balanced amounts. As a result, depositing and withdrawing can result in a partial loss of funds.
The GeVault.deposit() function is used by users to deposits funds into ticks and underlying Uniswap pools. The function takes funds from the caller and calls rebalance()
to distribute the funds among the ticks. The GeVault.rebalance() function first removes liquidity from all ticks and then deposits the removed assets plus the user assets back in to the ticks:
function rebalance() public { require(poolMatchesOracle(), "GEV: Oracle Error"); removeFromAllTicks(); if (isEnabled) deployAssets(); }
The GeVault.deployAssets()
function calls the GeVault.depositAndStash() function, which actually deposits tokens into a TokenisableRange
contract by calling the TokenisableRange.deposit(). The function deposits tokens into a Uniswap V3 pool and returns unspent tokens to the caller:
(uint128 newLiquidity, uint256 added0, uint256 added1) = POS_MGR.increaseLiquidity( ... ); ... _mint(msg.sender, lpAmt); TOKEN0.token.safeTransfer( msg.sender, n0 - added0); TOKEN1.token.safeTransfer( msg.sender, n1 - added1);
However, the GeVault.depositAndStash()
function doesn't handle the returned unspent tokens. Since Uniswap V3 pools take balanced token amounts (respective to the current pool price) and since the funds deposited into ticks are not balanced (deployAssets()
splits token amounts in halves), there's always a chance that the TokenisableRange.deposit()
function won't consume all specified tokens and will return some of them to the GeVault
contract. However, GeVault
won't return the unused tokens to the depositor.
Moreover, the contract won't include them in the TVL calculation:
getTickBalance(k)
) and the price of each LP token (t.latestAnswer()
), to compute the total value of the vault.Thus, the unused tokens will be locked in the contract until they're deposited into ticks. However, rebalancing and depositing of tokens can result in new unused tokens that won't be counted in the TVL.
Manual review
In the GeVault.deposit()
function, consider returning unspent tokens to the depositor. Extra testing is needed to guarantee that rebalancing doesn't result in unspent tokens, or, alternatively, such tokens could be counted in a storage variable and excluded from the balance of unspent tokens during depositing.
Alternatively, consider counting GeVault
's token balances in the getTVL()
function. This won't require returning unspent tokens during depositing and will allow depositors to withdraw their entire funds.
Token-Transfer
#0 - c4-pre-sort
2023-08-09T09:51:29Z
141345 marked the issue as primary issue
#1 - c4-sponsor
2023-08-15T00:28:49Z
Keref marked the issue as sponsor confirmed
#2 - Keref
2023-08-18T02:32:51Z
#3 - c4-judge
2023-08-19T16:26:53Z
gzeon-c4 marked the issue as satisfactory
#4 - gzeon-c4
2023-08-19T16:56:57Z
Looks like same root cause as #223 (totalTVL not accounting token in the contract)
#5 - c4-judge
2023-08-20T17:33:47Z
gzeon-c4 marked the issue as duplicate of #223
#6 - c4-judge
2023-08-20T17:35:21Z
gzeon-c4 marked the issue as selected for report
π Selected for report: said
Also found by: 3docSec, HChang26, Jeiwan, SpicyMeatball, giovannidisiena, jesusrod15, oakcobalt, pep7siup
91.1886 USDC - $91.19
The owner of the RangeManager
contract will lose funds during initialization of a range. Since the actual amounts of tokens deposited to a Uniswap V3 pool are computed on-chain during depositing, there's always a chance that some amounts provided by the owner won't be deposited and will be left in the contract. The funds can later be withdrawn by anyone.
The RangeManager.initRange() function lets the owner initialize a previously deployed TokenisableRange
contract. During the initialization, the owner deposits funds to the underlying Uniswap V3 pool of the TokenisableRange
contract by calling TokenisableRange.init()
:
TokenisableRange(tr).init(amount0, amount1);
The TokenisableRange.init() function deposits the tokens to the Uniswap V3 pool and returns unused tokens to the caller:
(tokenId, liquidity, , ) = POS_MGR.mint( ... ); // Transfer remaining assets back to user TOKEN0.token.safeTransfer( msg.sender, TOKEN0.token.balanceOf(address(this))); TOKEN1.token.safeTransfer(msg.sender, TOKEN1.token.balanceOf(address(this)));
However, the RangeManager.initRange()
function doesn't sent those tokens back to the owner: it only transfers the LP tokens. As a result, the unused tokens will remain locked in the contract. Of course, the owner can call initRange()
again to initialize another range and spent the leftover tokens. However, this call can also result in unspent tokens, thus there's always a chance that some tokens belonging to the owner will be left in the contract.
The leftover tokens can still be removed via the RangeManager.cleanup() function, which is called only in the RangeManager.removeAssetsFromStep(), which can be called by anyone. Consider this attack scenario:
RangeManager
creates a new range and initializes it to add liquidity.cleanup()
to transfer all asset tokens owned by the contract (which includes the owner leftover tokens) to the MEV bot.Manual review
In the RangeManager.initRange()
function, in addition to returning LP tokens to the caller, consider also return the asset tokens (ASSET_0
and ASSET_1
) to the caller.
Token-Transfer
#0 - c4-pre-sort
2023-08-09T07:50:37Z
141345 marked the issue as duplicate of #390
#1 - c4-pre-sort
2023-08-10T13:27:27Z
141345 marked the issue as duplicate of #254
#2 - c4-judge
2023-08-19T16:46:35Z
gzeon-c4 changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-08-20T17:37:00Z
gzeon-c4 marked the issue as satisfactory