Part 2

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

No one can redeem LP shares for reasonably high lockedCreditRatio and sufficiently high share distribution

Summary

Incorrect credit capacity check in VaultRouterBranch.redeem allows LPs withdraw more collateral than allowed. Furthermore, it prevents small amount of asset movement. Thus, if everyone's LP share amount is less than lockedCreditRatio * totalShare, no one can redeem any amount of LP shares.

Vulnerability Details

Root Cause Analysis

In VaultRouterBranch.redeem, there is a check to ensure vault's minimum credit cpapcity:

// if the credit capacity delta is greater than the locked credit capacity before the state transition, revert
if (
ctx.creditCapacityBeforeRedeemUsdX18.sub(vault.getTotalCreditCapacityUsd()).lte(
ctx.lockedCreditCapacityBeforeRedeemUsdX18.intoSD59x18()
)
) {
revert Errors.NotEnoughUnlockedCreditCapacity();
}

The comment and condition are completely wrong (with confliction as a bonus).

Consider the following example:

  • Vault's credit capacity: 2000 USD

  • Vault's locked credit ratio: 0.2

  • With the above check, the following will happen:

    • Any redeem lower than 400 USD collateral claim will be reverted

    • Any redeem greater than 400 USD will be successful

POC

The PoC demonstrates the following scenario:

  • vault.lockedCreditRatio = 0.5

  • Three users have equal share of index token

  • Any redeem try reverts with NotEnoughUnlockedCreditCapacity error

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 { 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 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 1 ** DEFAULT_DECIMAL;
}
}
contract MockEngine {
function getUnrealizedDebt(uint128) external pure returns (int256) {
return 0;
}
}
contract ReedemFailureTest 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;
uint128 marketId = 1;
uint128 vaultId = 1;
MockAsset asset;
MockZlpVault indexToken;
address usdc = vm.addr(2);
address weth = vm.addr(3);
uint256 userAssetAmount = 1000 * (10 ** DEFAULT_DECIMAL);
uint256 creditRatio = 1e18;
uint256[] vaultIds = new uint256[](1);
address[] users = new address[](3);
function setUp() external {
asset = new MockAsset();
indexToken = new MockZlpVault(vaultId, address(asset));
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] = 1;
market.id = marketId;
LiveMarkets.Data storage liveMarkets = LiveMarkets.load();
liveMarkets.addMarket(marketId);
Collateral.Data storage collateral = Collateral.load(address(asset));
collateral.isEnabled = true;
collateral.priceAdapter = address(priceAdapter);
collateral.creditRatio = creditRatio;
Vault.Data storage vault = Vault.load(vaultId);
vault.id = vaultId;
vault.isLive = true;
vault.indexToken = address(indexToken);
vault.depositCap = type(uint128).max;
vault.collateral.asset = address(asset);
vault.collateral.isEnabled = true;
vault.collateral.decimals = uint8(DEFAULT_DECIMAL);
vault.collateral.priceAdapter = address(priceAdapter);
vault.collateral.creditRatio = creditRatio;
vault.lockedCreditRatio = 0.5e18;
vault.wethRewardDistribution.setActorShares(bytes32(0), ud60x18(1e18));
_connectVaultsAndMarkets();
users[0] = makeAddr("alice");
users[1] = makeAddr("bob");
users[2] = makeAddr("eve");
for (uint256 i; i < users.length; i++) {
asset.mint(users[i], userAssetAmount);
vm.startPrank(users[i]);
asset.approve(address(this), userAssetAmount);
// each user will have equal 1000 share
IMarketMakingEngine(address(this)).deposit(vaultId, uint128(userAssetAmount), 0, "", false);
vm.stopPrank();
}
}
function testRevertOnRedeem(uint128 redeemAmount) external {
Vault.Data storage vault = Vault.load(vaultId);
_recalculateVaultsCreditCapacity();
for (uint256 i; i < users.length; i++) {
vm.startPrank(users[i]);
// try any redeem amount between dust and full share balance
redeemAmount = uint128(bound(uint256(redeemAmount), 1e15, indexToken.balanceOf(users[i])));
indexToken.approve(address(this), redeemAmount);
IMarketMakingEngine(address(this)).initiateWithdrawal(vaultId, redeemAmount);
uint128 withdrawalRequestId = vault.withdrawalRequestIdCounter[users[i]];
// always reverts because any redeem requires more than half of the asset withdrawal
vm.expectRevert(Errors.NotEnoughUnlockedCreditCapacity.selector);
IMarketMakingEngine(address(this)).redeem(vaultId, withdrawalRequestId, 0);
vm.stopPrank();
}
}
function _connectVaultsAndMarkets() internal {
uint256[] memory marketIds = new uint256[](1);
marketIds[0] = uint256(marketId);
uint256[] memory _vaultIds = vaultIds;
vm.startPrank(address(0));
IMarketMakingEngine(address(this)).connectVaultsAndMarkets(marketIds, _vaultIds);
vm.stopPrank();
}
function _recalculateVaultsCreditCapacity() internal {
IMarketMakingEngine(address(this)).updateMarketCreditDelegations(marketId);
}
}

Impact

  • For sufficiently high lockedCreditRatioand sufficiently high share distribution, no one can redeem shares, example:

    • Vault credit capacity: 2000 USD

    • Locked Credit Ratio: 0.2

    • 10 users have equal share of the vault

    • For any redeem deltaCredit < 2000 / 10 = 200 < 2000 * 0.2 = 400, so no one can redeem shares

  • For low lockedCreditRatio, LPs can redeem without any restriction

Tools Used

Manual Review

Recommendations

One should revisit lockedCreditRatio logic.

Updates

Lead Judging Commences

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

The check in VaultRouterBranch::redeem should be comparing remaining capacity against required locked capacity not delta against locked capacity

Support

FAQs

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