#36340 [SC-Insight] TimeBasedCollateralPool::_resetAccountTokenStateIfApplicable does not adjust tokenEpochExitBalances after redeeming the account's unstake Units
Submitted on Oct 30th 2024 at 08:37:16 UTC by @niroh for Audit Comp | Anvil
Report ID: #36340
Report Type: Smart Contract
Report severity: Insight
Target: https://etherscan.io/address/0xd042C267758eDDf34B481E1F539d637e41db3e5a
Impacts:
Temporary freezing of funds within the TimeBasedCollateralPool for at least 48 hours
Description
Brief/Intro
The TimeBasedCollateralPool _resetAccountTokenStateIfApplicable is called in _releaseEligibleAccountTokens to handle the case of a pool reset happening prior to the release that should affect the user. The function first handles the case of the account having a pending unstake that was already handled on the contract level before reset was called. In this case, the epoc pending units are first redeemed from the relevant epoc ExitBalance, and afterwards, if the account has additional pool units, they are redeemed from the reset exit balance:
(From TimeBasedCollateralPool.sol line 949) ```solidity // 2. Process and purge all account pending unstake state. There are two possibilities: // a. A pending unstake is vested or unvested but no vested contract unstake was processed prior to pool // reset. That contract state was purged in _resetPool(...), the account pool units still exist in the // vault, and we can release them via the standard "everything has been unstaked" logic below. // b. A pending unstake, was vested and the contract unstake was already processed at the time of pool reset. // In this case, the exchange rate was captured in tokenEpochExitBalances, and it's unsafe to process // this unstake any different than the standard release process. uint256 unstakeUnitsToRelease; { uint256 epoch = accountStateStorage.firstPendingUnstakeEpoch; if (epoch > 0) { uint256 currentEpoch = getCurrentEpoch(); if (epoch < currentEpoch) { // NB: This is case b. from above -- do not process contract-level unstakes that have not already been processed. (unstakeUnitsToRelease, _tokensToRelease) = getAccountExitUnitsAndTokens( _tokenAddress, epoch, accountStateStorage.firstPendingUnstakeUnits, false ); epoch = accountStateStorage.secondPendingUnstakeEpoch; if (_nonZeroAndLessThan(epoch, currentEpoch)) { (uint256 units, uint256 tokens) = getAccountExitUnitsAndTokens( _tokenAddress, epoch, accountStateStorage.secondPendingUnstakeUnits, false ); unstakeUnitsToRelease += units; _tokensToRelease += tokens; } }
```
Vulnerability Details
The problem is that is this scenario, while the user receives their releasable tokens for their pending epoc units, the ExitBalance struct is not updated and the released tokens/units are not substracted from it. This is inconistent with the way release is handled in the regular case (without a following reset), where the ExitBalance leftTokens/leftUnits are reduced with each account release: (From TimeBasedCollateralPool.sol line 845, _processAccountTokenUnstakes function) ```solidity /** Update epoch-exit state **/ tokenEpochExitBalances[_tokenAddress][epoch].unitsLeft -= _unitsToRelease; tokenEpochExitBalances[_tokenAddress][epoch].tokensLeft -= _tokensToRelease; ```
One of the sideeffects of this inconsistency in release calculations is that while the regular release process guarantees that all the exit balance tokens for that epoc will be distributed to users , in the reset case some residual exit balance remains unclaimable and permanently locked in the TBCP's vault account. The reason is that in the regular release process each user gets their share calculated from the reminder, up to the last user that gets all remaining tokens. In the reset case however, each user's share is calculated from the overall exit balance units/tokens. This causes rounding-down inacuraccies to accumulate and causes a situation that even after all units are claimed, not all exit balance tokens are transferred to the claimers (as vault available balance). Since the entire exit amount is released to the TBCP available balance, these residual tokens will remain in the TBCP vault available balance and will be locked there permanently.
Impact Details
Some residual tokens remain locked in the CollateralVault as residual TBCP available balance, accumulating over time.
References
Release without a reset Release after a reset
Proof of Concept
Proof of Concept
run instructions
This test was written in foundry. To run:
Install foundry and create a foundry project with `forge init`
cd to the project folder and install openzeppelin with `forge install OpenZeppelin/openzeppelin-contracts` and `forge install OpenZeppelin/openzeppelin-contracts-upgradeable`
Copy all files from the audit repo `contracts` folder to the foundry project `src` folder
Copy the following folders from the audit repo `node-modules` folder to the foundry `lib` folder: @pythnetwork, @uniswap
Add the following mappings to foundry.toml: remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts","@openzeppelin/contracts-upgradable=lib/openzeppelin-contracts-upgradable/contracts","@forge-std=lib/forge-std/src",]
Create a test file under the foundry `test` folder and copy the code below to it (replace YOUR_MAINNET_FORK_URL with your alchemy/other service url).
run `forge test -vvv --match-test testReleaseAfterReset`
Run once with the with_reset variable set to true and once as false to see the different effect.
test file code
```solidity pragma solidity >=0.8.0;
import {Test} from "@forge-std/Test.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import {console2} from "@forge-std/console2.sol"; import "./../src/CollateralVault.sol"; import "./../src/TimeBasedCollateralPool.sol"; import "./../src/VisibleBeaconProxy.sol"; import "@openzeppelin/contracts/utils/Strings.sol";
contract TestReleaseAfterReset is Test {
} ```