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.
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:
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:
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.
function test_lockedcreditrationotmaintained (uint128 vaultId,
uint256 assetsToDeposit,
uint128 marketId
)
external
{
vm.stopPrank();
VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);
vm.assume(fuzzVaultConfig.asset != address(wBtc));
vm.assume(fuzzVaultConfig.asset != address(usdc));
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
});
vm.prank( users.owner.account );
marketMakingEngine.updateVaultConfiguration(params);
uint128 depositCap = marketMakingEngine.workaround_Vault_getDepositCap(fuzzVaultConfig.vaultId);
console.log(depositCap);
address userA = users.naruto.account;
assetsToDeposit = depositCap;
fundUserAndDepositInVault(userA, fuzzVaultConfig.vaultId, uint128(assetsToDeposit));
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));
vm.startPrank(userA);
marketMakingEngine.initiateWithdrawal(fuzzVaultConfig.vaultId, userVaultShares);
skip(fuzzVaultConfig.withdrawalDelay + 1);
function getLockedCreditCapacityUsd1(uint128 vaultId)
external
view
returns (UD60x18 lockedCreditCapacityUsdX18)
{
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));
}
function getTotalCreditCapacityUsd1(uint128 vaultId) public view returns (SD59x18 creditCapacityUsdX18) {
Vault.Data storage self = Vault.loadLive(vaultId);
Collateral.Data storage collateral = self.collateral;
UD60x18 totalAssetsX18 = ud60x18(IERC4626(self.indexToken).totalAssets());
UD60x18 totalAssetsUsdX18 = collateral.getAdjustedPrice().mul(totalAssetsX18);
creditCapacityUsdX18 = totalAssetsUsdX18.intoSD59x18().sub(Vault.getTotalDebt(self));
}
*/
SD59x18 totalcreditcapacitypreredeem = marketMakingEngine.getTotalCreditCapacityUsd1(fuzzVaultConfig.vaultId);
console.log("totalcreditcapacitypreredeem",totalcreditcapacitypreredeem.unwrap());
UD60x18 lockedcreditcapacity = marketMakingEngine.getLockedCreditCapacityUsd1(fuzzVaultConfig.vaultId);
console.log("lockedcreditcapacity", lockedcreditcapacity.unwrap());
marketMakingEngine.redeem(fuzzVaultConfig.vaultId, 1, 0);
vm.stopPrank();
SD59x18 totalcreditcapacitypostredeem = marketMakingEngine.getTotalCreditCapacityUsd1(fuzzVaultConfig.vaultId);
console.log("totalcreditcapacitypostredeem",totalcreditcapacitypostredeem.unwrap());
assert(uint256(totalcreditcapacitypostredeem.unwrap()) < lockedcreditcapacity.unwrap());
assertEq(totalcreditcapacitypostredeem.unwrap(), 0);
}
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.