Part 2

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

market's realized debt per vault share is not updated after clearing market's debt

Summary

Protocol does not distribute debt to vaults if market's realized debt and unrealized debt are both 0. However, market debt has been cleared by either converting deposits to usdc or depositing usd token from perps engine. In this case, market's total debt is zero but its realized debt per vault share is not updated.

Vulnerability Details

Root Cause Analysis

When recalculating vault's connected markets state, if market's realized debt and unrealized debt are both zero, it skips updating market's debt per vault share:

// if market has debt distribute it
if (!ctx.marketUnrealizedDebtUsdX18.isZero() || !ctx.marketRealizedDebtUsdX18.isZero()) {
// distribute the market's debt to its connected vaults
market.distributeDebtToVaults(ctx.marketUnrealizedDebtUsdX18, ctx.marketRealizedDebtUsdX18);
}

However, market's debt can be cleared by:

  • converting credit deposits to usdc, or

  • depositing usd token back to market

Protocol won't reflect this change to market's debt per vault share attributes in this case.

POC

The following POC demonstrates the scenario when market has 1000 usd debt from usd token issuance and then get its debt cleared by depositing 1000 usd token back into the market making engine. Although market's total debt is zero afterwards, market's realizedDebtPerVaultShare remains unchanged.

import { IMarketMakingEngine } from "@zaros/market-making/MarketMakingEngine.sol";
import { ZlpVault } from "@zaros/zlp/ZlpVault.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 { Distribution } from "@zaros/market-making/leaves/Distribution.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 { StabilityConfiguration } from "@zaros/market-making/leaves/StabilityConfiguration.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 { IERC20 } from "@openzeppelin/token/ERC20/ERC20.sol";
import { Errors } from "@zaros/utils/Errors.sol";
import { ERC20Mock } from "@openzeppelin/mocks/token/ERC20Mock.sol";
import "forge-std/Test.sol";
uint256 constant DEFAULT_DECIMAL = 18;
contract MockAsset is ERC20Mock { }
contract MockUSDT is ERC20Mock { }
contract MockUSDC is ERC20Mock { }
contract MockWeth is ERC20Mock { }
contract MockZlpVault is ZlpVault {
bytes32 private constant ZLP_VAULT_STORAGE_LOCATION =
keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ZlpVault")) - 1)) & ~bytes32(uint256(0xff));
bytes32 private constant ERC4626StorageLocation =
0x0773e532dfede91f04b12a73d3d2acd361424f41f76b4fb79f090161e36b4e00;
function _getZlpVaultStorageOverride() private pure returns (ZlpVaultStorage storage zlpVaultStorage) {
bytes32 slot = ZLP_VAULT_STORAGE_LOCATION;
assembly {
zlpVaultStorage.slot := slot
}
}
function _getERC4626StorageOverride() private pure returns (ERC4626Storage storage $) {
assembly {
$.slot := ERC4626StorageLocation
}
}
constructor(uint128 vaultId, address asset_) {
ZlpVaultStorage storage zlpVaultStorage = _getZlpVaultStorageOverride();
zlpVaultStorage.marketMakingEngine = msg.sender;
zlpVaultStorage.vaultId = vaultId;
ERC4626Storage storage $ = _getERC4626StorageOverride();
$._asset = IERC20(asset_);
$._underlyingDecimals = uint8(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 MarketMakingEngineTest is Test, IMarketMakingEngine {
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;
using Distribution for Distribution.Data;
using StabilityConfiguration for StabilityConfiguration.Data;
uint128 dexSwapStrategyId = 1;
MockAsset asset;
MockEngine mockEngine;
MockUSDT mockUsdt;
MockUSDC mockUsdc;
MockWeth mockWeth;
MockPriceAdapter priceAdapter;
MockZlpVault indexToken;
uint256 userAssetAmount = 1000 * (10 ** DEFAULT_DECIMAL);
uint256 creditRatio = 10 ** DEFAULT_DECIMAL;
uint256[] marketIds = new uint256[](1);
uint256[] vaultIds = new uint256[](1);
uint128 marketId = 1;
uint128 vaultId = 1;
address user = makeAddr("alice");
function setUp() external {
asset = new MockAsset();
priceAdapter = new MockPriceAdapter();
mockEngine = new MockEngine();
mockUsdt = new MockUSDT();
mockUsdc = new MockUSDC();
mockWeth = new MockWeth();
MarketMakingEngineConfiguration.Data storage configuration = MarketMakingEngineConfiguration.load();
configuration.weth = address(mockWeth);
configuration.usdc = address(mockUsdc);
configuration.isRegisteredEngine[address(mockEngine)] = true;
configuration.usdTokenOfEngine[address(mockEngine)] = address(mockUsdt);
configuration.isSystemKeeperEnabled[address(this)] = true;
marketIds[0] = uint256(marketId);
vaultIds[0] = uint256(vaultId);
LiveMarkets.Data storage liveMarkets = LiveMarkets.load();
_setUpCollaterals();
Market.Data storage market = Market.load(marketId);
market.id = marketId;
market.engine = address(mockEngine);
market.autoDeleverageStartThreshold = uint128(10 ** (DEFAULT_DECIMAL - 1)); // 0.1
market.autoDeleverageEndThreshold = uint128(10 ** DEFAULT_DECIMAL); // 1
market.autoDeleverageExponentZ = uint128(3 * 10 ** DEFAULT_DECIMAL); // 3
liveMarkets.addMarket(marketId);
indexToken = new MockZlpVault(vaultId, address(asset));
indexToken.updateAssetAllowance(type(uint128).max);
Vault.Data storage vault = Vault.load(vaultId);
vault.id = vaultId;
vault.isLive = true;
vault.indexToken = address(indexToken);
vault.depositCap = type(uint128).max;
vault.collateral.decimals = uint8(DEFAULT_DECIMAL);
vault.collateral.priceAdapter = address(priceAdapter);
vault.collateral.creditRatio = creditRatio;
vault.collateral.asset = address(asset);
vault.collateral.isEnabled = true;
vault.engine = address(mockEngine);
// deposit to vault 1000 USD worth of collateral
vm.startPrank(user);
asset.mint(user, userAssetAmount);
asset.approve(address(this), userAssetAmount);
IMarketMakingEngine(address(this)).deposit(vaultId, uint128(userAssetAmount), 0, "", false);
vm.stopPrank();
_connectVaultsAndMarkets();
}
function testDebtPerVaultShareDoesNotChange() external {
Market.Data storage market = Market.load(marketId);
_recalculateVaultsCreditCapacity();
vm.startPrank(address(mockEngine));
// mint 1000 USDz from market - this will add 1000 USD debt to market
IMarketMakingEngine(address(this)).withdrawUsdTokenFromMarket(marketId, userAssetAmount);
_recalculateVaultsCreditCapacity();
// market total debt is 1000 USD
assertEq(market.getTotalDebt().unwrap(), int256(userAssetAmount));
// market realized debt usd per vault share is 1 USD
assertEq(int256(market.realizedDebtUsdPerVaultShare), int256(10 ** DEFAULT_DECIMAL));
mockUsdt.approve(address(this), userAssetAmount);
// market debt is cleared by depositing 1000 USDz back
IMarketMakingEngine(address(this)).depositCreditForMarket(marketId, address(mockUsdt), userAssetAmount);
_recalculateVaultsCreditCapacity();
// market total debt is 0
assertEq(market.getTotalDebt().unwrap(), 0);
// however, market realized debt usd per share stays the same
assertEq(int256(market.realizedDebtUsdPerVaultShare), int256(10 ** DEFAULT_DECIMAL));
vm.stopPrank();
}
function _recalculateVaultsCreditCapacity() internal {
for (uint256 i; i < marketIds.length; i++) {
IMarketMakingEngine(address(this)).updateMarketCreditDelegations(uint128(marketIds[i]));
}
}
function _connectVaultsAndMarkets() internal {
uint256[] memory _marketIds = marketIds;
uint256[] memory _vaultIds = vaultIds;
vm.startPrank(address(0));
IMarketMakingEngine(address(this)).connectVaultsAndMarkets(marketIds, _vaultIds);
vm.stopPrank();
}
function _setUpCollaterals() internal {
address[] memory collaterals = new address[](4);
collaterals[0] = address(asset);
collaterals[1] = address(mockUsdt);
collaterals[2] = address(mockUsdc);
collaterals[3] = address(mockWeth);
for (uint256 i; i < collaterals.length; i++) {
address collateralAddr = collaterals[i];
Collateral.Data storage collateral = Collateral.load(address(collateralAddr));
collateral.isEnabled = true;
collateral.priceAdapter = address(priceAdapter);
collateral.creditRatio = creditRatio;
collateral.decimals = uint8(DEFAULT_DECIMAL);
}
}
}

Impact

Vault's debt will not be decreased even though connected market debt has been cleared. This will lead to the following:

  • Connected vaults' LP redeemers will get less assets than deserved

  • usd token swappers will get less assets than deserved

Tools Used

Manual Review, Foundry

Recommendations

Remove the zero check altogether.

Updates

Lead Judging Commences

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

Vault::_recalculateConnectedMarketsState doesn't update market's realized debt per vault share after clearing market's debt, remove the zero check

Support

FAQs

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