Part 2

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

Locked credit capacity enforcement can be broken leading to vault inadequacy to cover debt position

Summary

VaultRouterBranch:: compares the delta (difference) between the vault’s credit capacity before and after the redeem to a locked credit threshold, rather than ensuring the vault’s final credit capacity remains above that threshold. As a result, users may be able to redeem assets even if it causes the vault’s remaining credit capacity to fall below the required locked level, potentially destabilizing the protocol’s financial guarantees.

Vulnerability Details

The following check is made in VaultRouterBranch::redeem :

if (
ctx.creditCapacityBeforeRedeemUsdX18
.sub(vault.getTotalCreditCapacityUsd())
.lte(ctx.lockedCreditCapacityBeforeRedeemUsdX18.intoSD59x18())
) {
revert Errors.NotEnoughUnlockedCreditCapacity();
}

the code is comparing the difference in credit capacity (beforeRedeem - afterRedeem) to the locked capacity threshold (ctx.lockedCreditCapacityBeforeRedeemUsdX18) to attempt to make sure that if the credit capacity delta is greater than the locked credit capacity before the state transition the function reverts . In the code block, this isnt what happens as it ends up checking if the credit capacity delta is less than or equal to the locked credit capacity before the state transition.

This is one of a two fold issue. The second issue is with the underlying logic because the intended goal is to ensure that after redeeming, the vault’s remaining credit capacity stays above the locked threshold.
The intended design intends the vault always to maintain at least a certain amount of credit capacity (i.e., lockedCreditCapacity), as per the natspec which defines Vault::lockedcreditratio as:

/// @param lockedCreditRatio The configured ratio that determines how much of the vault's total assets can't be
/// withdrawn according to the Vault's total debt, in order to secure the credit delegation system.

Due to the flawed comparison, a malicious or opportunistic user might be able to redeem an amount that causes the vault’s final credit capacity to drop below its required locked threshold without triggering an error or reverting.

Locked credit capacity is calculated in Vault::getLockedCreditCapacityUsd. See below:

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

The total credit capacity is calculated by multiplying the total credit capacity by the lockedcreditratio set by the protocol when configuring the vault via Vault::update.

Proof Of Code (POC)

function test_lockedcreditrationotmaintained (uint128 vaultId,
uint256 assetsToDeposit,
uint128 marketId
)
external
{
vm.stopPrank();
//c configure vaults and markets
VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);
vm.assume(fuzzVaultConfig.asset != address(wBtc)); //c to avoid overflow issues
vm.assume(fuzzVaultConfig.asset != address(usdc)); //c to log market debt
PerpMarketCreditConfig memory fuzzMarketConfig = getFuzzPerpMarketCreditConfig(marketId);
uint256[] memory marketIds = new uint256[](1);
marketIds[0] = fuzzMarketConfig.marketId;
uint256[] memory vaultIds = new uint256[](1);
vaultIds[0] = fuzzVaultConfig.vaultId;
vm.prank(users.owner.account);
marketMakingEngine.connectVaultsAndMarkets(marketIds, vaultIds);
Vault.UpdateParams memory params = Vault.UpdateParams({
vaultId: fuzzVaultConfig.vaultId,
depositCap: 10e18,
withdrawalDelay: fuzzVaultConfig.withdrawalDelay,
isLive: true,
lockedCreditRatio: 0.5e18
});
//c update lockedcreditratio of vault by updating vault configuration
vm.prank( users.owner.account );
marketMakingEngine.updateVaultConfiguration(params);
//c get updated deposit cap of vault
uint128 depositCap = marketMakingEngine.workaround_Vault_getDepositCap(fuzzVaultConfig.vaultId);
console.log(depositCap);
//c user deposits into vault
address userA = users.naruto.account;
assetsToDeposit = depositCap;
fundUserAndDepositInVault(userA, fuzzVaultConfig.vaultId, uint128(assetsToDeposit));
//c perp engine deposits credit into market to incur debt
deal(fuzzVaultConfig.asset, address(fuzzMarketConfig.engine), 100e18);
vm.prank(address(fuzzMarketConfig.engine));
marketMakingEngine.depositCreditForMarket(fuzzMarketConfig.marketId, fuzzVaultConfig.asset, fuzzVaultConfig.depositCap);
uint128 userVaultShares = uint128(IERC20(fuzzVaultConfig.indexToken).balanceOf(userA));
// initiate the withdrawal
vm.startPrank(userA);
marketMakingEngine.initiateWithdrawal(fuzzVaultConfig.vaultId, userVaultShares);
// fast forward block.timestamp to after withdraw delay has passed
skip(fuzzVaultConfig.withdrawalDelay + 1);
//c get totalcreditcapacity before redeem
/*c to run these tests, I added the following functions to vaultrouterbranch:
//c for testing purposes
function getLockedCreditCapacityUsd1(uint128 vaultId)
external
view
returns (UD60x18 lockedCreditCapacityUsdX18)
{
// load the vault storage pointer
Vault.Data storage self = Vault.loadLive(vaultId);
SD59x18 creditCapacityUsdX18 = getTotalCreditCapacityUsd1(vaultId);
lockedCreditCapacityUsdX18 = creditCapacityUsdX18.lte(SD59x18.wrap(0))
? UD60x18.wrap(0)
: creditCapacityUsdX18.intoUD60x18().mul(ud60x18(self.lockedCreditRatio));
}
//c for testing purposes
function getTotalCreditCapacityUsd1(uint128 vaultId) public view returns (SD59x18 creditCapacityUsdX18) {
// load the vault storage pointer
Vault.Data storage self = Vault.loadLive(vaultId);
// load the collateral configuration storage pointer
Collateral.Data storage collateral = self.collateral;
// fetch the zlp vault's total assets amount
UD60x18 totalAssetsX18 = ud60x18(IERC4626(self.indexToken).totalAssets());
// calculate the total assets value in usd terms
UD60x18 totalAssetsUsdX18 = collateral.getAdjustedPrice().mul(totalAssetsX18);
// calculate the vault's credit capacity in usd terms
creditCapacityUsdX18 = totalAssetsUsdX18.intoSD59x18().sub(Vault.getTotalDebt(self));
}
*/
SD59x18 totalcreditcapacitypreredeem = marketMakingEngine.getTotalCreditCapacityUsd1(fuzzVaultConfig.vaultId);
console.log("totalcreditcapacitypreredeem",totalcreditcapacitypreredeem.unwrap());
//c get lockedcreditcapacity
UD60x18 lockedcreditcapacity = marketMakingEngine.getLockedCreditCapacityUsd1(fuzzVaultConfig.vaultId);
console.log("lockedcreditcapacity", lockedcreditcapacity.unwrap());
//c user redeem all their assets successfully
marketMakingEngine.redeem(fuzzVaultConfig.vaultId, 1, 0);
vm.stopPrank();
//c the total credit capacity of the vault will now be less than the locked credit capacity due to the withdrawal which is not intended behaviour
SD59x18 totalcreditcapacitypostredeem = marketMakingEngine.getTotalCreditCapacityUsd1(fuzzVaultConfig.vaultId);
console.log("totalcreditcapacitypostredeem",totalcreditcapacitypostredeem.unwrap());
assert(uint256(totalcreditcapacitypostredeem.unwrap()) < lockedcreditcapacity.unwrap());
assertEq(totalcreditcapacitypostredeem.unwrap(), 0);
}

Impact

Systemic Risk: Once the vault’s post-redeem credit capacity falls below the locked threshold, the protocol may be unable to honor future credit obligations or maintain sufficient reserves, putting the entire system at risk.
Financial Losses: If the vault cannot cover its obligations, users or other stakeholders could suffer substantial losses.
Exploitation Potential: Attackers may repeatedly exploit this bug to drain vault capacity, leading to destabilization and potential insolvency for the protocol.

Tools Used

Manual Review, Foundry

Recommendations

Compare Final Capacity to Locked Threshold:
Replace the capacity-delta check with a direct comparison between the vault’s new (post-redeem) total credit capacity and the locked threshold. For example:

if (vault.getTotalCreditCapacityUsd()).lt(ctx.lockedCreditCapacityBeforeRedeemUsdX18.intoSD59x18())) {
revert Errors.NotEnoughUnlockedCreditCapacity();
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

The check in VaultRouterBranch::redeem should be comparing remaining capacity against required locked capacity not delta against locked capacity

Support

FAQs

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