Part 2

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

The protocol will be DOSed after a redeem

Summary

Because of an arithmetic underflow in the recalculateVaultsCreditCapacitythe vault is totally DOSed after a redemption.

Vulnerability Details

For every change in the state of a vault, the function recalculateVaultsCreditCapacityVault:369 is called. The purpose of this function is to recalculate the latest credit capacity of the provided vault IDs, taking into account their most recent asset and debt values, denominated in USD.


The weight of all credit delegations is set to the current vault assets, as we can see here (Vault:521):

// get the total of shares
uint128 newWeight = uint128(IERC4626(self.indexToken).totalAssets());
for (uint256 i; i < connectedMarketsIdsCache.length; i++) {
// load the credit delegation to the given market id
CreditDelegation.Data storage creditDelegation =
CreditDelegation.load(self.id, connectedMarkets.at(i).toUint128());
// update the credit delegation weight
creditDelegation.weight = newWeight;
}

The function calls _updateCreditDelegations Vault:584 to update the credit delegations of the vault and compute the new totalDelegatedCreditUsd of the market, as well as the new valueUSD of the credit delegation, as illustrated here:

uint128 totalCreditDelegationWeightCache = self.totalCreditDelegationWeight;
if (totalCreditDelegationWeightCache != 0) {
// get the latest credit delegation share of the vault's credit capacity
UD60x18 creditDelegationShareX18 =
ud60x18(creditDelegation.weight).div(ud60x18(totalCreditDelegationWeightCache));
// stores the vault's total credit capacity to be returned
vaultCreditCapacityUsdX18 = getTotalCreditCapacityUsd(self);
// if the vault's credit capacity went to zero or below, we set its credit delegation to that market
// to zero
UD60x18 newCreditDelegationUsdX18 = vaultCreditCapacityUsdX18.gt(SD59x18_ZERO)
? vaultCreditCapacityUsdX18.intoUD60x18().mul(creditDelegationShareX18)
: UD60x18_ZERO;
// calculate the delta applied to the market's total delegated credit
UD60x18 creditDeltaUsdX18 = newCreditDelegationUsdX18.sub(previousCreditDelegationUsdX18);
// loads the market's storage pointer and update total delegated credit
Market.Data storage market = Market.load(connectedMarketId);
market.updateTotalDelegatedCredit(creditDeltaUsdX18);
// if new credit delegation is zero, we clear the credit delegation storage
if (newCreditDelegationUsdX18.isZero()) {
creditDelegation.clear();
} else {
// update the credit delegation stored usd value
creditDelegation.valueUsd = newCreditDelegationUsdX18.intoUint128();
}
}
}

The problem occurs in the calculation of creditDeltaUsdX18. If the system generates more debt or if the amount of assets decreases, the newCreditDelegationUsdX18 will be lower than the previousCreditDelegationUsdX18. As a result, the call will revert after a redeem or any other function that modifies the state of the vault and decrease the credit delegation value.

POC

YYou can run the POC by copying and pasting this code into the redeem.t.sol file and executing the following command: forge test --mt test_UnderflowPOC -vvv

You should have this output :

Ran 1 test suite in 768.95ms (44.09ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/integration/market-making/vault-router-branch/redeem/redeem.t.sol:Redeem_Integration_Test
[FAIL: panic: arithmetic underflow or overflow (0x11)] test_UnderflowPOC() (gas: 1788080)
function test_UnderflowPOC() public {
vm.stopPrank();
uint128 vaultId= INITIAL_VAULT_ID;
uint128 assetsToDeposit= uint128(calculateMinOfSharesToStake(vaultId));
uint128 sharesToWithdraw;
// ensure valid vault and load vault config
VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);
// ensure valid deposit amount
address user = users.naruto.account;
// peform the deposit
fundUserAndDepositInVault(user, vaultId, assetsToDeposit);
uint128 userVaultShares = uint128(IERC20(fuzzVaultConfig.indexToken).balanceOf(user));
sharesToWithdraw = userVaultShares/2;
// intiate the withdrawal
vm.startPrank(user);
marketMakingEngine.initiateWithdrawal(vaultId, sharesToWithdraw);
// fast forward block.timestamp to after withdraw delay has passed
skip(fuzzVaultConfig.withdrawalDelay + 1);
UD60x18 expectedAssetsX18 = marketMakingEngine.getIndexTokenSwapRate(vaultId, sharesToWithdraw, false);
uint256 redeemFee = vaultsConfig[vaultId].redeemFee;
UD60x18 expectedAssetsMinusRedeemFeeX18 = expectedAssetsX18.sub(expectedAssetsX18.mul(ud60x18(redeemFee)));
UD60x18 sharesMinusRedeemFeesX18 =
marketMakingEngine.getVaultAssetSwapRate(vaultId, expectedAssetsMinusRedeemFeeX18.intoUint256(), false);
// save and verify pre state
RedeemState memory pre = _getRedeemState(
user, users.vaultFeeRecipient.account, IERC20(fuzzVaultConfig.asset), IERC20(fuzzVaultConfig.indexToken)
);
assertEq(pre.marketEngineVaultBal, sharesToWithdraw, "MarketEngine received shares from initiated withdraw");
assertEq(pre.redeemerVaultBal, userVaultShares - sharesToWithdraw, "Shares deducted from Redeemer");
assertEq(pre.feeReceiverAssetBal + pre.vaultAssetBal, assetsToDeposit, "All deposited assets accounted");
// perform the redemption
vm.expectEmit();
emit VaultRouterBranch.LogRedeem(vaultId, user, sharesMinusRedeemFeesX18.intoUint256());
marketMakingEngine.redeem(vaultId, WITHDRAW_REQUEST_ID, 0);
// save and verify post state
RedeemState memory post = _getRedeemState(
user, users.vaultFeeRecipient.account, IERC20(fuzzVaultConfig.asset), IERC20(fuzzVaultConfig.indexToken)
);
// verify withdrawal request marked as fulfilled
WithdrawalRequest.Data memory withdrawalRequest =
marketMakingEngine.exposed_WithdrawalRequest_loadExisting(vaultId, user, WITHDRAW_REQUEST_ID);
assertTrue(withdrawalRequest.fulfilled);
// verify redeem fees paid to the vault redeem fee recipient
assertEq(
post.feeReceiverAssetBal - pre.feeReceiverAssetBal,
expectedAssetsX18.mul(ud60x18(redeemFee)).intoUint256(),
"Redeem fees paid to FeeReceiver"
);
assertEq(post.redeemerVaultBal, userVaultShares - sharesToWithdraw, "Shares deducted from Redeemer");
assertEq(post.marketEngineVaultBal, 0, "No shares stuck in market engine");
assertEq(
post.redeemerAssetBal,
expectedAssetsMinusRedeemFeeX18.intoUint256(),
"Redeemer received correct asset tokens"
);
assertEq(
post.redeemerAssetBal + post.feeReceiverAssetBal + post.vaultAssetBal,
assetsToDeposit,
"All deposited assets accounted"
);
marketMakingEngine.initiateWithdrawal(vaultId, sharesToWithdraw);
skip(fuzzVaultConfig.withdrawalDelay + 1);
marketMakingEngine.redeem(vaultId, WITHDRAW_REQUEST_ID +1, 0);
}

Impact

Since this function is called every time the state of the vault is modified, the vault is completely DOSed.

Tools Used

Echidna

Recommendations

The protocol should change the type of the creditDeltaUsdX18 in order to be negative :

SD59x18 previousCreditDelegationUsdX18 = ud60x18(creditDelegation.valueUsd).intoSD59x18();
...
SD59x18 creditDeltaUsdX18 = newCreditDelegationUsdX18.intoSD59x18().sub(previousCreditDelegationUsdX18);
Updates

Lead Judging Commences

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

`Vault::recalculateVaultsCreditCapacity` function always reverts after first redeem causing protocol DoS

Appeal created

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

Vault::_updateCreditDelegations uses unsigned UD60x18 for credit delegation delta calculation which will underflow on any decrease in credit delegation amount

Support

FAQs

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