Part 2

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

Vault's total credit capacity keeps changing when being recalculated even though there is no market activity

Summary

Circular dependency between calculations of market's totalDelegatedCreditUsd and vault's depositedUsdc will lead to inconsistent calculation result for every Vault.recalculateVaultsCreditCapacity function call.

Vulnerability Details

Root Cause Analysis

Circular dependency can be illustrated as follows:

circular dependency

i.e. market.totalDelegatedCreditUsd-> vault.totalCreditCapacityUsd-> vault.depositedUsdc-> market.usdcCreditPerVaultShare -> market.totalDelegatedCreditUsd

Let's see why it is like that:

  • market.totalDelegatedCreditUsd is a credit-delegation-weighted sum of vault's totalCreditCapacityUsd ref

    • Thus, market.totalDelegatedCreditUsd depends on vault.totalCreditCapacityUsd

  • vault.totalCreditCapacityUsd is totalAssetsUsd - vault.marketsRealizedDebtUsd + vault.depositedUsdc - vault.marketsUnrealizedDebtUsd ref

    • Thus vault.totalCreditCapacityUsd depends on vault.depositedUsdc

  • vault.depositedUsdc is a credit-delegation-weighted sum of market.usdcCreditPerVaultShare ref

    • Thus vault.depositedUsdc depends on market.usdcCreditPerVaultShare

  • market.usdcCreditPerVaultShare is netUsdcReceived / market.totalDelegatedCreditUsd ref

    • Thus market.usdcCreditPerVaultShare depends on market.totalDelegatedCreditUsd

This loophole will lead to inconsistent vault credit capacity calculation.

POC

In the POC, market receives some usdc deposit. After that, vault.getTotalCreditCapacityUsd() is compared before previous result after every Vault.recalculateVaultsCreditCapacity function call.

Due to the loophole described above, change in market.usdcCreditPerVaultShare leads to change in market.totalDelegatedCreditUsd, which leads to change in vault.depositedUsdc, which leads to market.usdcCreditPerVaultShare and so on.

The calculation finally settles after 6th-call of Vault.recalculateVaultsCreditCapacity due to the limitation of fixed math calculation.

pragma solidity 0.8.25;
import { CreditDelegationBranch } from "@zaros/market-making/branches/CreditDelegationBranch.sol";
import { VaultRouterBranch } from "@zaros/market-making/branches/VaultRouterBranch.sol";
import { MarketMakingEngineConfigurationBranch } from
"@zaros/market-making/branches/MarketMakingEngineConfigurationBranch.sol";
import { Vault } from "@zaros/market-making/leaves/Vault.sol";
import { Market } from "@zaros/market-making/leaves/Market.sol";
import { CreditDelegation } from "@zaros/market-making/leaves/CreditDelegation.sol";
import { MarketMakingEngineConfiguration } from "@zaros/market-making/leaves/MarketMakingEngineConfiguration.sol";
import { LiveMarkets } from "@zaros/market-making/leaves/LiveMarkets.sol";
import { Collateral } from "@zaros/market-making/leaves/Collateral.sol";
import { UD60x18, ud60x18 } from "@prb-math/UD60x18.sol";
import { SD59x18, sd59x18 } from "@prb-math/SD59x18.sol";
import { Constants } from "@zaros/utils/Constants.sol";
import { SafeCast } from "@openzeppelin/utils/math/SafeCast.sol";
import { EnumerableMap } from "@openzeppelin/utils/structs/EnumerableMap.sol";
import { EnumerableSet } from "@openzeppelin/utils/structs/EnumerableSet.sol";
import { IERC4626 } from "@openzeppelin/token/ERC20/extensions/ERC4626.sol";
import { Errors } from "@zaros/utils/Errors.sol";
import "forge-std/Test.sol";
uint256 constant DEFAULT_DECIMAL = 18;
contract MockVault {
function totalAssets() external pure returns (uint256) {
return 1000 * (10 ** DEFAULT_DECIMAL);
}
}
contract MockPriceAdapter {
uint256 price = 10 ** DEFAULT_DECIMAL;
function getPrice() external view returns (uint256) {
return price;
}
function setPrice(uint256 newPrice) external {
price = newPrice;
}
}
contract MockEngine {
function getUnrealizedDebt(uint128) external pure returns (int256) {
return 0;
}
}
contract MarketMakingConfigurationBranchTest is
CreditDelegationBranch,
VaultRouterBranch,
MarketMakingEngineConfigurationBranch,
Test
{
using Vault for Vault.Data;
using Market for Market.Data;
using CreditDelegation for CreditDelegation.Data;
using Collateral for Collateral.Data;
using SafeCast for uint256;
using EnumerableSet for EnumerableSet.UintSet;
using EnumerableMap for EnumerableMap.AddressToUintMap;
using LiveMarkets for LiveMarkets.Data;
using MarketMakingEngineConfiguration for MarketMakingEngineConfiguration.Data;
uint128 marketId = 1;
uint128 vaultId = 1;
address asset = vm.addr(1);
address usdc = vm.addr(2);
address weth = vm.addr(3);
uint256 collateralAssetAmount = 1000 * (10 ** DEFAULT_DECIMAL);
uint256 creditRatio = 1e18;
MockPriceAdapter priceAdapter;
uint256[] vaultIds = new uint128[](1);
function setUp() external {
MockVault indexToken = new MockVault();
priceAdapter = new MockPriceAdapter();
MockEngine mockEngine = new MockEngine();
MarketMakingEngineConfiguration.Data storage configuration = MarketMakingEngineConfiguration.load();
configuration.usdc = usdc;
Market.Data storage market = Market.load(marketId);
market.engine = address(mockEngine);
uint256[] memory marketIds = new uint256[](1);
marketIds[0] = uint256(marketId);
vaultIds[0] = uint256(vaultId);
market.id = marketId;
LiveMarkets.Data storage liveMarkets = LiveMarkets.load();
liveMarkets.addMarket(marketId);
Collateral.Data storage collateral = Collateral.load(asset);
collateral.priceAdapter = address(priceAdapter);
collateral.creditRatio = creditRatio;
Vault.Data storage vault = Vault.load(vaultId);
vault.id = vaultId;
vault.indexToken = address(indexToken);
vault.collateral.decimals = uint8(DEFAULT_DECIMAL);
vault.collateral.priceAdapter = address(priceAdapter);
vault.collateral.creditRatio = creditRatio;
uint256[] memory _vaultIds = vaultIds;
_connectVaultsAndMarkets(_vaultIds);
}
function testCircularDependency() external {
Market.Data storage market = Market.load(marketId);
Vault.Data storage vault = Vault.load(vaultId);
_recalculateVaultsCreditCapacity();
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, uint256(marketId));
market.depositCredit(asset, ud60x18(collateralAssetAmount));
market.settleCreditDeposit(usdc, ud60x18(1e18));
market.receiveWethReward(weth, ud60x18(0), ud60x18(1e18));
_recalculateVaultsCreditCapacity();
market.settleCreditDeposit(usdc, ud60x18(100_000e18));
_recalculateVaultsCreditCapacity();
SD59x18 previousTotalDelegatedCreditUsd = vault.getTotalCreditCapacityUsd();
uint256 count;
// loop until previous calculation result equals to new one
// notice there is no market activity, it's just recalculation
for (;;) {
_recalculateVaultsCreditCapacity();
if (previousTotalDelegatedCreditUsd == vault.getTotalCreditCapacityUsd()) {
break;
}
previousTotalDelegatedCreditUsd = vault.getTotalCreditCapacityUsd();
count++;
}
console.log("Calculation settled after n-th try:", count);
}
function _connectVaultsAndMarkets(uint256[] memory _vaultIds) internal {
uint256[] memory marketIds = new uint256[](1);
marketIds[0] = uint256(marketId);
vm.startPrank(address(0));
MarketMakingConfigurationBranchTest(address(this)).connectVaultsAndMarkets(marketIds, _vaultIds);
vm.stopPrank();
}
function _recalculateVaultsCreditCapacity() internal {
MarketMakingConfigurationBranchTest(address(this)).updateMarketCreditDelegations(marketId);
}
}

Console Output

[PASS] testCircularDependency() (gas: 728958)
Logs:
Calculation settled after n-th try: 6

Impact

This inconsistent update will result in incorrect index token swap rate and usdt token swap rate. It will bring user fund loss and protocol reputation damage.

Tools Used

Foundry, Manual Review

Recommendations

This seems like a protocol design issue. One needs to break circular dependency in calculation logic.

Updates

Lead Judging Commences

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

Circular dependency in vault credit capacity calculation

Support

FAQs

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