Part 2

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

Lack of credit capacity update from VaultRouterBranch::deposit causes DOS in CreditDelegationBranch::depositcreditformarket

Summary

In the VaultRouterBranch::deposit function, the Vault::recalculateVaultsCreditCapacity function is called before the deposited collateral is accounted for in the vault's total assets. This results in incorrect credit delegation calculations, causing the depositCreditForMarket function to fail even when the vault has sufficient collateral. This bug disrupts the protocol's core functionality, preventing perp engine from depositing credit into markets.

Vulnerability Details

The vulnerability is located in the VaultRouterBranch::deposit function, specifically in the sequence of operations where Vault::recalculateVaultsCreditCapacity is called before the deposited collateral is added to the vault's total assets. See VaultRouterBranch::deposit:

/// @notice Deposits a given amount of collateral assets into the provided vault in exchange for index tokens.
/// @dev Invariants involved in the call:
/// The total deposits MUST not exceed the vault after the deposit.
/// The number of received shares MUST be greater than or equal to minShares.
/// The number of received shares MUST be > 0 even when minShares = 0.
/// The Vault MUST exist.
/// The Vault MUST be live.
/// If the vault enforces fees then calculated deposit fee must be non-zero.
/// No tokens should remain stuck in this contract.
/// @param vaultId The vault identifier.
/// @param assets The amount of collateral to deposit, in the underlying ERC20 decimals.
/// @param minShares The minimum amount of index tokens to receive in 18 decimals.
/// @param referralCode The referral code to use.
/// @param isCustomReferralCode True if the referral code is a custom referral code.
function deposit(
uint128 vaultId,
uint128 assets,
uint128 minShares,
bytes memory referralCode,
bool isCustomReferralCode
)
external
{
if (assets == 0) revert Errors.ZeroInput("assets");
// load the mm engine configuration from storage
MarketMakingEngineConfiguration.Data storage marketMakingEngineConfiguration =
MarketMakingEngineConfiguration.load();
// enforce whitelist if enabled
address whitelistCache = marketMakingEngineConfiguration.whitelist;
if (whitelistCache != address(0)) {
if (!Whitelist(whitelistCache).verifyIfUserIsAllowed(msg.sender)) {
revert Errors.UserIsNotAllowed(msg.sender);
}
}
// fetch storage slot for vault by id, vault must exist with valid collateral
Vault.Data storage vault = Vault.loadLive(vaultId);
if (!vault.collateral.isEnabled) revert Errors.VaultDoesNotExist(vaultId);
// define context struct and get vault collateral asset
DepositContext memory ctx;
ctx.vaultAsset = vault.collateral.asset;
// prepare the `Vault::recalculateVaultsCreditCapacity` call
uint256[] memory vaultsIds = new uint256[]();
vaultsIds[0] = uint256(vaultId);
// recalculates the vault's credit capacity
// note: we need to update the vaults credit capacity before depositing new assets in order to calculate the
// correct conversion rate between assets and shares, and to validate the involved invariants accurately
Vault.recalculateVaultsCreditCapacity(vaultsIds);
// load the referral module contract
ctx.referralModule = IReferral(marketMakingEngineConfiguration.referralModule);
// register the given referral code
if (referralCode.length != 0) {
ctx.referralModule.registerReferral(
abi.encode(msg.sender), msg.sender, referralCode, isCustomReferralCode
);
}
// cache the vault assets decimals value for gas savings
ctx.vaultAssetDecimals = vault.collateral.decimals;
// uint256 -> ud60x18 18 decimals
ctx.assetsX18 = Math.convertTokenAmountToUd60x18(ctx.vaultAssetDecimals, assets);
// cache the deposit fee
ctx.vaultDepositFee = ud60x18(vault.depositFee);
// if deposit fee is zero, skip needless processing
if (ctx.vaultDepositFee.isZero()) {
ctx.assetsMinusFees = assets;
} else {
// otherwise calculate the deposit fee
ctx.assetFeesX18 = ctx.assetsX18.mul(ctx.vaultDepositFee);
// ud60x18 -> uint256 asset decimals
ctx.assetFees = Math.convertUd60x18ToTokenAmount(ctx.vaultAssetDecimals, ctx.assetFeesX18);
// invariant: if vault enforces fees then calculated fee must be non-zero
if (ctx.assetFees == 0) revert Errors.ZeroFeeNotAllowed();
// enforce positive amount left over after deducting fees
ctx.assetsMinusFees = assets - ctx.assetFees;
if (ctx.assetsMinusFees == 0) revert Errors.DepositTooSmall();
}
// transfer tokens being deposited minus fees into this contract
IERC20(ctx.vaultAsset).safeTransferFrom(msg.sender, address(this), ctx.assetsMinusFees);
// transfer fees from depositor to fee recipient address
if (ctx.assetFees > 0) {
IERC20(ctx.vaultAsset).safeTransferFrom(
msg.sender, marketMakingEngineConfiguration.vaultDepositAndRedeemFeeRecipient, ctx.assetFees
);
}
// increase vault allowance to transfer tokens minus fees from this contract to vault
address indexTokenCache = vault.indexToken;
IERC20(ctx.vaultAsset).approve(indexTokenCache, ctx.assetsMinusFees);
// then perform the actual deposit
// NOTE: the following call will update the total assets deposited in the vault
// NOTE: the following call will validate the vault's deposit cap
// invariant: no tokens should remain stuck in this contract
ctx.shares = IERC4626(indexTokenCache).deposit(ctx.assetsMinusFees, msg.sender);
// assert min shares minted
if (ctx.shares < minShares) revert Errors.SlippageCheckFailed(minShares, ctx.shares);
// invariant: received shares must be > 0 even when minShares = 0; no donation allowed
if (ctx.shares == 0) revert Errors.DepositMustReceiveShares();
// emit an event
emit LogDeposit(vaultId, msg.sender, ctx.assetsMinusFees);
}

The incorrect logic lies in the sequence of operations where in VaultRouterBranch::deposit, Vault::recalculateVaultsCreditCapacity is called before the deposited collateral is transferred to the vault.This means the vault's total assets used in credit capacity calculations including Vault::updateVaultAndCreditDelegationWeight and especially Vault::_updateCreditDelegations where market::totaldelegatedcreditusd is updated do not include the newly deposited collateral.

Vault::recalculateVaultsCreditCapacity function updates the credit delegation for connected markets based on the vault's current total assets (excluding the new deposit).As a result, the Market::totalDelegatedCreditUsd value is not updated to reflect the new deposit, leading to incorrect credit capacity calculations.

CreditDelegation::depositCreditForMarket function checks if Market::totalDelegatedCreditUsd is greater than zero before allowing credit deposits. Due to the incorrect credit delegation calculations, this check fails even when the vault has sufficient collateral, causing the function to revert with Errors.NoDelegatedCredit.

Proof Of Code (POC)

function test\_doswhenuserhasdepositedbutdelegatedmarketcreditis0(
uint256 vaultId,
uint256 marketId
) external {
vm.stopPrank();
VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);
vm.assume(fuzzVaultConfig.asset != address(usdc));
PerpMarketCreditConfig memory fuzzMarketConfig = getFuzzPerpMarketCreditConfig(marketId);
//c set up 1 vault and 1 market
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);
//c set delegatedmarketcredit to 0 using workaround
marketMakingEngine.workaround_updateMarketTotalDelegatedCreditUsd(fuzzMarketConfig.marketId, 0);
//c deposit assets into vault to increase the vaults credit capacity to 1e18 as well as market::totaldelegatedcreditusd to 1e18
address user = users.naruto.account;
deal(fuzzVaultConfig.asset, user, 100e18);
vm.startPrank(user);
marketMakingEngine.deposit(fuzzVaultConfig.vaultId, 1e18, 0, "", false);
vm.stopPrank();
//c DOS occurs here due to the lack of correct update of totaldelegatedcreditusd. market has delegated credit but credit capacity was not updated successfully
vm.prank(address(fuzzMarketConfig.engine));
vm.expectRevert(abi.encodeWithSelector(Errors.NoDelegatedCredit.selector, fuzzMarketConfig.marketId));
marketMakingEngine.depositCreditForMarket(fuzzMarketConfig.marketId, fuzzVaultConfig.asset, 1e17);

}

Impact

Disruption of Core Functionality: Perp Engine cannot deposit credit into markets, even when the vault has sufficient collateral. This prevents the protocol from functioning as intended, disrupting trading and liquidity provision. Traders and liquidity providers may be unable to execute trades or earn rewards, leading to potential financial losses.

Tools Used

Manual Review, Foundry

Recommendations

Update CreditDelegationBranch::depositcreditformarket as follows:

function depositCreditForMarket(
uint128 marketId,
address collateralAddr,
uint256 amount
)
external
onlyRegisteredEngine(marketId)
{
if (amount == 0) revert Errors.ZeroInput("amount");
// Loads the collateral's data storage pointer, must be enabled
Collateral.Data storage collateral = Collateral.load(collateralAddr);
collateral.verifyIsEnabled();
// Loads the market's data storage pointer, must have delegated credit
Market.Data storage market = Market.loadLive(marketId);
// Get the connected vaults for the market
uint256[] memory connectedVaults = market.getConnectedVaultsIds();
// Recalculate the credit capacity for the connected vaults
Vault.recalculateVaultsCreditCapacity(connectedVaults);
// Ensure the market has delegated credit after recalculating
if (market.getTotalDelegatedCreditUsd().isZero()) {
revert Errors.NoDelegatedCredit(marketId);
}
// uint256 -> UD60x18 scaling decimals to zaros internal precision
UD60x18 amountX18 = collateral.convertTokenAmountToUd60x18(amount);
// Caches the usdToken address
address usdToken = MarketMakingEngineConfiguration.load().usdTokenOfEngine[msg.sender];
// Caches the usdc
address usdc = MarketMakingEngineConfiguration.load().usdc;
// Note: storage updates must occur using zaros internal precision
if (collateralAddr == usdToken) {
// If the deposited collateral is USD Token, it reduces the market's realized debt
market.updateNetUsdTokenIssuance(unary(amountX18.intoSD59x18()));
} else {
if (collateralAddr == usdc) {
market.settleCreditDeposit(address(0), amountX18);
} else {
// Deposits the received collateral to the market to be distributed to vaults
// to be settled in the future
market.depositCredit(collateralAddr, amountX18);
}
}
// Transfers the margin collateral asset from the registered engine to the market making engine
// NOTE: The engine must approve the market making engine to transfer the margin collateral asset, see
// PerpsEngineConfigurationBranch::setMarketMakingEngineAllowance
// Note: transfers must occur using token native precision
IERC20(collateralAddr).safeTransferFrom(msg.sender, address(this), amount);
// Emit an event
emit LogDepositCreditForMarket(msg.sender, marketId, collateralAddr, amount);
}

Vault::recalculateVaultsCreditCapacity call ensures that the Market::totalDelegatedCreditUsd value is updated to reflect the latest vault balances. This prevents the NoDelegatedCredit error because the market will have the correct delegated credit value. If this is the first deposit into a vault, Vault::recalculateVaultsCreditCapacity call ensures that the market's delegated credit is updated to reflect the new deposit. This allows the CreditDelegationBranch::depositCreditForMarket function to proceed without reverting.

The recommendation helps maintains consistency across functions. CreditDelegationBranch::withdrawUsdTokenFromMarket function already includes this logic, so adding it to CreditDelegationBranch::depositCreditForMarket ensures consistency across similar operations. This makes the codebase more predictable and easier to maintain. Also, if multiple deposits or withdrawals occur in quick succession, the new solution ensures that the credit delegation state is always up-to-date before processing any credit-related operations. This reduces the risk of race conditions or stale state issues.

Updates

Lead Judging Commences

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

Credit capacity calculation uses stale total assets in VaultRouterBranch::deposit by updating before the actual deposit, causing DOS in depositCreditForMarket

Support

FAQs

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