Part 2

Zaros
PerpetualsDEXFoundrySolidity
70,000 USDC
View results
Submission Details
Severity: high
Invalid

Users are able to `redeem` even when Vault is insolvent because a wrong value returned by `getLockedCreditCapacityUsd`

Summary

When total credit capacity of a vault is zero or even negative (more debt than assets), Vault::getLockedCreditCapacityUsd returns 0, signaling no credit capacity is locked. This allow depositors to redeem shares when Vault is in debt, increasing the vault's debt even more.

Vulnerability Details

Users who provided liquidity to vaults can redeem their share by calling initiateWithdrawal followed by redeem.
First, the available credit capacity and the locked credit capacity are cached. Then the actual redeem takes places where shares are burnt and assets are transferred from Vault to user, decreasing the vault's credit capacity.
Lastly the code should checks if there's enough credit capacity left in the vault.

The total credit capacity of a vault is calculated as assetValue - debt, a negative value meaning vault is insolvent.
When credit capacity is lower or equal to 0, getLockedCreditCapacityUsd returns 0.

// Vault.sol
function getLockedCreditCapacityUsd(Data storage self)
internal
view
returns (UD60x18 lockedCreditCapacityUsdX18)
{
SD59x18 creditCapacityUsdX18 = getTotalCreditCapacityUsd(self);
// @audit if creditCapacity is 0 entire amount should be locked
// function should return type(uint256).max, not 0
lockedCreditCapacityUsdX18 = creditCapacityUsdX18.lte(SD59x18_ZERO)
@> ? UD60x18_ZERO
: creditCapacityUsdX18.intoUD60x18().mul(ud60x18(self.lockedCreditRatio));
}

In redeem there's this check: if(ccBeforeRedeem - ccAfterRedeem < lockedCcBeforeRedeem) revert

Note: There's another issue related to "unlocked credit capacity" check reported in a separate submission. Solving that one as initially intended will not fix both problems without introducing new ones. Both fixes must be implemented as suggested to remove both issues.

function redeem(uint128 vaultId, uint128 withdrawalRequestId, uint256 minAssets) external {
...
// cache the vault's credit capacity before redeeming
ctx.creditCapacityBeforeRedeemUsdX18 = vault.getTotalCreditCapacityUsd();
// cache the locked credit capacity before redeeming
ctx.lockedCreditCapacityBeforeRedeemUsdX18 = vault.getLockedCreditCapacityUsd();
// redeem shares previously transferred to the contract at `initiateWithdrawal` and store the returned assets
address indexToken = vault.indexToken;
uint256 assets =
IERC4626(indexToken).redeem(ctx.sharesMinusRedeemFeesX18.intoUint256(), msg.sender, address(this));// transfer asset to user, burn vault shares
...
// if the credit capacity delta is greater than the locked credit capacity before the state transition, revert
if (
@> ctx.creditCapacityBeforeRedeemUsdX18.sub(vault.getTotalCreditCapacityUsd()).lte(
ctx.lockedCreditCapacityBeforeRedeemUsdX18.intoSD59x18()
)
) {
revert Errors.NotEnoughUnlockedCreditCapacity();
}
...
}

Consideer the follwing example:

  • ccBeforeRedeem : - 100 (usd value)

  • lockedCcBeforeRedeem : 0

  • user wants to redeem 20 usd worth of assets => ccAfterRedeem = -100 - 20 = -120
    With these values, when the locked credit capacity check is executed:
    if(-100 - (-120) < 0) <=> if(20 < 0) which is false and the redeem doesn't revert as intended.

Impact

When the vault is insolvent LPs can withdraw their liquidity. Profitable traders will not be able to swap usdToken for vault's assets, incurring a loss.

Tools Used

Recommendations

Update getLockedCreditCapacityUsd to return type(uint256).max when there's no credit capacity left in vault. In this way you notify the caller the entire credit capacity is locked.

Note: as mentioned earlier, both fixes, this and the one recommended in the second issue must be implemented to remove both problems.

function getLockedCreditCapacityUsd(Data storage self)
internal
view
returns (UD60x18 lockedCreditCapacityUsdX18)
{
SD59x18 creditCapacityUsdX18 = getTotalCreditCapacityUsd(self);
lockedCreditCapacityUsdX18 = creditCapacityUsdX18.lte(SD59x18_ZERO)
- ? UD60x18_ZERO
+ ? type(uint256).max;
: creditCapacityUsdX18.intoUD60x18().mul(ud60x18(self.lockedCreditRatio));
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
4 months ago
inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

alexczm Submitter
4 months ago
inallhonesty Lead Judge
4 months ago
inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.