Summary
Market::getCreditCapacityUsd function located in CreditDelegationBranch::withdrawUsdTokenFromMarket, which incorrectly calculates the credit capacity of a market by adding the total debt to the delegated credit instead of subtracting it. This flaw leads to an overestimation of the market's credit capacity, potentially allowing the system to operate in an unsafe state where it cannot cover its liabilities. This issue is particularly severe because it affects the core logic of credit delegation and debt management in the protocol and allows an attacker to mint an amount of usdz that the protocol cannot back 1:1 with usdc which breaks a key protocol invariant.
Vulnerability Details
CreditDelegationBranch::withdrawUsdTokenFromMarket contains the following code:
function withdrawUsdTokenFromMarket(uint128 marketId, uint256 amount) external onlyRegisteredEngine(marketId) {
Market.Data storage market = Market.loadLive(marketId);
uint256[] memory connectedVaults = market.getConnectedVaultsIds();
Vault.recalculateVaultsCreditCapacity(connectedVaults);
SD59x18 marketTotalDebtUsdX18 = market.getTotalDebt();
UD60x18 delegatedCreditUsdX18 = market.getTotalDelegatedCreditUsd();
SD59x18 creditCapacityUsdX18 = Market.getCreditCapacityUsd(delegatedCreditUsdX18, marketTotalDebtUsdX18);
if (creditCapacityUsdX18.lte(SD59x18_ZERO)) {
revert Errors.InsufficientCreditCapacity(marketId, creditCapacityUsdX18.intoInt256());
}
UD60x18 amountX18 = ud60x18(amount);
uint256 amountToMint = amount;
if (market.isAutoDeleverageTriggered(delegatedCreditUsdX18, marketTotalDebtUsdX18)) {
UD60x18 adjustedUsdTokenToMintX18 =
market.getAutoDeleverageFactor(delegatedCreditUsdX18, marketTotalDebtUsdX18).mul(amountX18);
amountToMint = adjustedUsdTokenToMintX18.intoUint256();
market.updateNetUsdTokenIssuance(adjustedUsdTokenToMintX18.intoSD59x18());
} else {
market.updateNetUsdTokenIssuance(amountX18.intoSD59x18());
}
MarketMakingEngineConfiguration.Data storage marketMakingEngineConfiguration =
MarketMakingEngineConfiguration.load();
UsdToken usdToken = UsdToken(marketMakingEngineConfiguration.usdTokenOfEngine[msg.sender]);
usdToken.mint(msg.sender, amountToMint);
emit LogWithdrawUsdTokenFromMarket(msg.sender, marketId, amount, amountToMint);
}
The vulnerability is located in the getCreditCapacityUsd function, which is used in the above function to calculate the market's credit capacity. See Market::getCreditCapacityUsd below:
```solidity
function getCreditCapacityUsd(
UD60x18 delegatedCreditUsdX18,
SD59x18 totalDebtUsdX18
)
internal
pure
returns (SD59x18 creditCapacityUsdX18)
{
creditCapacityUsdX18 = delegatedCreditUsdX18.intoSD59x18().add(totalDebtUsdX18);
}
```
The incorrect logic is as follows:
```solidity
SD59x18 creditCapacityUsdX18 = Market.getCreditCapacityUsd(delegatedCreditUsdX18, marketTotalDebtUsdX18);
Incorrect Logic:
Market::getCreditCapacityUsd function currently adds the total debt (marketTotalDebtUsdX18) to the delegated credit (delegatedCreditUsdX18). This is mathematically incorrect because:
Credit Capacity should represent the remaining capacity of the market to take on additional debt.
The correct formula should be:
Credit Capacity = Delegated Credit - Total Debt
Impact
Overestimation of Credit Capacity:
The current implementation overestimates the market's credit capacity, making it appear as though the market has more capacity to take on debt than it actually does.
For example:
Delegated Credit = 1000 USD
Total Debt = 300 USD
Correct Credit Capacity = 1000 - 300 = 700 USD
Bugged Credit Capacity = 1000 + 300 = 1300 USD
Unsafe System State:
The system may allow withdrawals or minting of USD tokens even when the market does not have sufficient credit capacity to cover its liabilities.
This could lead to insolvency if the market's debt exceeds its delegated credit.
Inconsistency with Vault::_updateCreditDelegations:
Vault::_updateCreditDelegations function correctly calculates credit capacity by subtracting total debt from delegated credit. This inconsistency further highlights the bug in getCreditCapacityUsd. See below:
function _updateCreditDelegations(
Data storage self,
uint128[] memory connectedMarketsIdsCache,
bool shouldRehydrateCache
)
private
returns (uint128[] memory rehydratedConnectedMarketsIdsCache, SD59x18 vaultCreditCapacityUsdX18)
{
rehydratedConnectedMarketsIdsCache = new uint128[](connectedMarketsIdsCache.length);
uint128 vaultId = self.id;
uint256 connectedMarketsConfigLength = self.connectedMarkets.length;
EnumerableSet.UintSet storage connectedMarkets = self.connectedMarkets[connectedMarketsConfigLength - 1];
for (uint256 i; i < connectedMarketsIdsCache.length; i++) {
if (shouldRehydrateCache) {
rehydratedConnectedMarketsIdsCache[i] = connectedMarkets.at(i).toUint128();
} else {
rehydratedConnectedMarketsIdsCache[i] = connectedMarketsIdsCache[i];
}
uint128 connectedMarketId = rehydratedConnectedMarketsIdsCache[i];
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, connectedMarketId);
UD60x18 previousCreditDelegationUsdX18 = ud60x18(creditDelegation.valueUsd);
uint128 totalCreditDelegationWeightCache = self.totalCreditDelegationWeight;
if (totalCreditDelegationWeightCache != 0) {
UD60x18 creditDelegationShareX18 =
ud60x18(creditDelegation.weight).div(ud60x18(totalCreditDelegationWeightCache));
vaultCreditCapacityUsdX18 = getTotalCreditCapacityUsd(self);
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);
if (newCreditDelegationUsdX18.isZero()) {
creditDelegation.clear();
} else {
creditDelegation.valueUsd = newCreditDelegationUsdX18.intoUint128();
}
}
The correct calculation is where the vaultCreditCapacityUsdX18 is calculated using Vault::getTotalCreditCapacityUsd. See function below:
function getTotalCreditCapacityUsd(Data storage self) internal view returns (SD59x18 creditCapacityUsdX18) {
Collateral.Data storage collateral = self.collateral;
UD60x18 totalAssetsX18 = ud60x18(IERC4626(self.indexToken).totalAssets());
UD60x18 totalAssetsUsdX18 = collateral.getAdjustedPrice().mul(totalAssetsX18);
creditCapacityUsdX18 = totalAssetsUsdX18.intoSD59x18().sub(getTotalDebt(self));
}
As seen above, the credit capacity is calculated with Credit Capacity = Delegated Credit - Total Debt .
This vulnerability has a critical impact on the protocol's financial stability and security. Specifically:
Financial Losses:
The protocol may allow withdrawals or minting of USD tokens beyond the market's actual capacity, leading to undercollateralization and potential insolvency.
System Instability:
The incorrect credit capacity calculation could cause the system to operate in an unsafe state, where it cannot cover its liabilities.
Exploitation Risk:
Malicious actors could exploit this bug to drain funds from the protocol by artificially inflating the market's credit capacity.
Proof Of Code (POC)
Running the following test will show the exploit:
function test_creditcapacitywrongcalculation(
uint256 marketId,
uint256 amount,
uint256 amounttodepositcredit,
uint128 vaultId,
uint64 anyamount
)
external
{
amounttodepositcredit = bound({ x: amount, min: 1, max: type(uint32).max });
PerpMarketCreditConfig memory fuzzMarketConfig = getFuzzPerpMarketCreditConfig(marketId);
vaultId = uint128(bound(vaultId, INITIAL_VAULT_ID, FINAL_VAULT_ID));
VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);
vm.assume(fuzzVaultConfig.asset != address(usdc));
deal({ token: address(fuzzVaultConfig.asset), to: address(fuzzMarketConfig.engine), give: amounttodepositcredit*10 });
changePrank({ msgSender: address(fuzzMarketConfig.engine) });
marketMakingEngine.depositCreditForMarket(fuzzMarketConfig.marketId, fuzzVaultConfig.asset, amounttodepositcredit);
uint256 mmBalance = IERC20(fuzzVaultConfig.asset).balanceOf(address(marketMakingEngine));
uint128 depositFee = uint128(vaultsConfig[fuzzVaultConfig.vaultId].depositFee);
_setDepositFee(depositFee, fuzzVaultConfig.vaultId);
address user = users.naruto.account;
deal(fuzzVaultConfig.asset, user, 100e18);
vm.startPrank(user);
marketMakingEngine.deposit(vaultId, 1e18, 0, "", false);
vm.stopPrank();
vm.prank(address(fuzzMarketConfig.engine));
marketMakingEngine.depositCreditForMarket(fuzzMarketConfig.marketId, fuzzVaultConfig.asset, amounttodepositcredit);
SD59x18 marketdebt = marketMakingEngine.workaround_getTotalMarketDebt(fuzzMarketConfig.marketId);
console.log(marketdebt.unwrap());
uint256 anyamount = bound(uint256(anyamount), 1, type(uint64).max);
vm.prank(address(fuzzMarketConfig.engine));
marketMakingEngine.withdrawUsdTokenFromMarket(fuzzMarketConfig.marketId, anyamount);
UD60x18 totaldelegatedcreditusd = marketMakingEngine.workaround_getTotalDelegatedCreditUsd(fuzzMarketConfig.marketId);
console.log(totaldelegatedcreditusd.unwrap());
SD59x18 expectedcreditcapacity = totaldelegatedcreditusd.intoSD59x18().sub(marketdebt);
SD59x18 actualcreditcapacity = totaldelegatedcreditusd.intoSD59x18().add(marketdebt);
assert(expectedcreditcapacity.unwrap() != actualcreditcapacity.unwrap());
}
Tools Used
Manual Review, Foundry
Recommendations
Update Market::getCreditCapacityUsd as follows:
function getCreditCapacityUsd(
UD60x18 delegatedCreditUsdX18,
SD59x18 totalDebtUsdX18
)
internal
pure
returns (SD59x18 creditCapacityUsdX18)
{
creditCapacityUsdX18 = delegatedCreditUsdX18.intoSD59x18().sub(totalDebtUsdX18);
}