Part 2

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

Market-vault disconnection will bring permanent inconsistent state

Summary

In the protocol, for the most of the time, market and vault's state vars are updated by deltas, not by direct updation. This delta-change operation does not work well when market and vault connection is changed. It doesn't recognize delta by broken connection. As a result, when a vault is unlinked from a market, market's delegated credit usd will remain the same. Vauilt's state vars (marketRealizedDebtUsd etc) won't reflect broken connection, either.

Vulnerability Details

Root Cause

Market and vault's connection is updated by MarketMakingEngineConfigurationBranch.connectVaultAndMarkets.

The owner can add or remove vaults and markets connection by calling this external function.

After connections are updated, vaults and markets state vars will be updated by Vault.recalculateVaultsCreditCapacity. This function logic is very complicated but can be summarized as the following:

  1. Update weights for all credit delegations for connected markets

  2. Calculate deltas of realizedDebtChange, unrealizedDebtChange, usdcCreditChange, wethRewardChange between vault and connected markets

  3. Update vault's states by deltas calculated above

  4. For all connected markets, do the following:

    1. Calculate creditDelegation delta

    2. Update market's totalCreditDelegation by the above delta

While this approach works for existing connections and new one, it doesn't work well for removed connections.

Removed connections are counted out from delta calculation, so market's totalCreditDelegation won't be deducted even after a vault is disconnected.

Same thing will happen for vaults as well. i.e. Their state vars won't reflect market disconnection change.

UD60x18 previousCreditDelegationUsdX18 = ud60x18(creditDelegation.valueUsd);
...
if (totalCreditDelegationWeightCache != 0) {
...
UD60x18 newCreditDelegationUsdX18 = vaultCreditCapacityUsdX18.gt(SD59x18_ZERO)
? vaultCreditCapacityUsdX18.intoUD60x18().mul(creditDelegationShareX18)
: UD60x18_ZERO;
UD60x18 creditDeltaUsdX18 = newCreditDelegationUsdX18.sub(previousCreditDelegationUsdX18);
Market.Data storage market = Market.load(connectedMarketId);
market.updateTotalDelegatedCredit(creditDeltaUsdX18); // @audit creditDeltaUsdX18 does not consider removed connection
...
}

POC

The following POC demonstrates the following scenario:

  • Market 1 was connected to Vaults 1, 2

  • Market's totalDelegatedCreditUsd is 1600 and creditCapacityUsdis 1680

  • Market 1is connected to Vault 1. Vault 2has been disconnected from the market

  • Recalculate market's credit deposits

  • Market's updated totalDelegatedCreditUsdis still 1600 and creditCapacityUsdstill remains 1680

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 {
function getPrice() external pure returns (uint256) {
return 10 ** DEFAULT_DECIMAL;
}
}
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;
address asset = vm.addr(1);
address usdc = vm.addr(2);
uint256 collateralAssetAmount = 100 * (10 ** DEFAULT_DECIMAL);
uint256 usdcAmount = 200 * (10 ** DEFAULT_DECIMAL);
uint256 creditRatio = 0.8e18;
uint256[] vaultIds = new uint128[](2);
function setUp() external {
MockVault indexToken = new MockVault();
MockPriceAdapter 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(1);
vaultIds[1] = uint256(2);
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;
// setup Vault 1 and Vault 2
for (uint128 vaultId = 1; vaultId <= 2; vaultId++) {
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;
}
// connect Market to Vault 1 and Vault 2
uint256[] memory _vaultIds = vaultIds;
_connectVaultsAndMarkets(_vaultIds);
}
function testInconsistencyAfterDisconnection() external {
Market.Data storage market = Market.load(marketId);
_recalculateVaultsCreditCapacity();
// deposit some vaules to demonstrate real-world scenario
market.depositCredit(asset, ud60x18(collateralAssetAmount));
market.settleCreditDeposit(usdc, ud60x18(100e18));
_recalculateVaultsCreditCapacity();
assertEq(market.getConnectedVaultsIds().length, 2);
console.log("\nConnected Vault IDs: [1, 2]\n");
_logMarketState();
// Market is connected to Vault 1.
uint256[] memory _vaultIds = new uint256[](1);
_vaultIds[0] = 1;
_connectVaultsAndMarkets(_vaultIds);
market.receiveWethReward(vm.addr(3), ud60x18(0), ud60x18(1e18));
// Vault 2 is dropped from connection
assertEq(market.getConnectedVaultsIds().length, 1);
_recalculateVaultsCreditCapacity();
console.log("\nConnected Vault IDs: [1]\n");
_logMarketState();
}
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);
}
function _logMarketState() internal {
Market.Data storage market = Market.load(marketId);
UD60x18 creditDepositsValueUsdX18 = market.getCreditDepositsValueUsd();
SD59x18 marketTotalDebtUsdX18 = market.getTotalDebt();
UD60x18 delegatedCreditUsdX18 = market.getTotalDelegatedCreditUsd();
SD59x18 creditCapacityUsdX18 = Market.getCreditCapacityUsd(delegatedCreditUsdX18, marketTotalDebtUsdX18);
emit log_named_decimal_uint("creditDepositsValueUsd", creditDepositsValueUsdX18.unwrap(), DEFAULT_DECIMAL);
emit log_named_decimal_int("marketTotalDebtUsd", marketTotalDebtUsdX18.unwrap(), DEFAULT_DECIMAL);
emit log_named_decimal_uint("delegatedCreditUsd", delegatedCreditUsdX18.unwrap(), DEFAULT_DECIMAL);
emit log_named_decimal_int("creditCapacityUsd", creditCapacityUsdX18.unwrap(), DEFAULT_DECIMAL);
}
}

Console Output

[PASS] testInconsistencyAfterDisconnection() (gas: 777559)
Logs:
Connected Vault IDs: [1, 2]
creditDepositsValueUsd: 80.000000000000000000
marketTotalDebtUsd: 80.000000000000000000
delegatedCreditUsd: 1600.000000000000000000
creditCapacityUsd: 1680.000000000000000000
Connected Vault IDs: [1]
creditDepositsValueUsd: 80.000000000000000000
marketTotalDebtUsd: 80.000000000000000000
delegatedCreditUsd: 1600.000000000000000000
creditCapacityUsd: 1680.000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.80ms (838.85µs CPU time)

Impact

Once a vault-market connection is unlinked, the protocol will be in inconsistent state.

This inconsistency will affect usd token swap rate, vault index token swap rate and ultimately, will lead to user fund loss and protocol's reputation damage.

Tools Used

Manual Review, Foundry

Recommendations

Connection updating logic and Vault.recalculateVaultsCreditCapacity should be changed to handle disconnection scenario.

Updates

Lead Judging Commences

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

Market.totalDelegatedCredit is not decremented when vaults are disconnected, causing markets to permanently retain stale credit delegation values

Support

FAQs

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