Part 2

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

Total market debt > 0 when credit deposits > netusdissuance which breaks key protocol logic

Summary

A miscalculation in market debt accounting within the Zaros protocol causes incorrect credit and debt allocations to vaults. The current implementation inverts the expected logic, leading to markets appearing in debt when they should be in credit, and vice versa. This issue stems from how credit deposits and net USD token issuance are combined in the debt formula.

Vulnerability Details

The explanation of how the protocol should operate based on the specification given by the zaros team which is when traders realize a negative pnl, i.e they lose money in the perp engine, they send assets from their margin balance to cover the notional value of the loss. Those assets are credited to vaults delegating liquidity to the respective market that made the trader realize the loss,
so a vault is in credit when the net sum of its markets debt/credit realizations is negative, i.e it has received more assets than it had to issue usdTokens to cover profitable trades. It is in debt when that same value is positive, representing the inverse scenario (usdToken issuance greater than the usd val of assets collected). Debt value should be negative if credit deposits > net usd issuance.

This is not the case in the code as CreditDelegationBranch::depositcreditformarket has the following code:

function depositCreditForMarket(
uint128 marketId,
address collateralAddr,
uint256 amount
)
external
onlyRegisteredEngine(marketId)
{
if (amount == 0) revert Errors.ZeroInput("amount");
// loads the collateral's data storage pointer, must be enabled
Collateral.Data storage collateral = Collateral.load(collateralAddr);
collateral.verifyIsEnabled();
// loads the market's data storage pointer, must have delegated credit so
// engine is not depositing credit to an empty distribution (with 0 total shares)
// although this should never happen if the system functions properly.
Market.Data storage market = Market.loadLive(marketId);
if (market.getTotalDelegatedCreditUsd().isZero()) {
revert Errors.NoDelegatedCredit(marketId);
}
// uint256 -> UD60x18 scaling decimals to zaros internal precision
UD60x18 amountX18 = collateral.convertTokenAmountToUd60x18(amount);
// caches the usdToken address
address usdToken = MarketMakingEngineConfiguration.load().usdTokenOfEngine[msg.sender];
// caches the usdc
address usdc = MarketMakingEngineConfiguration.load().usdc;
// note: storage updates must occur using zaros internal precision
if (collateralAddr == usdToken) {
// if the deposited collateral is USD Token, it reduces the market's realized debt
market.updateNetUsdTokenIssuance(unary(amountX18.intoSD59x18()));
} else {
if (collateralAddr == usdc) {
market.settleCreditDeposit(address(0), amountX18);
} else {
// deposits the received collateral to the market to be distributed to vaults
// to be settled in the future
market.depositCredit(collateralAddr, amountX18);
}
}
// transfers the margin collateral asset from the registered engine to the market making engine
// NOTE: The engine must approve the market making engine to transfer the margin collateral asset, see
// PerpsEngineConfigurationBranch::setMarketMakingEngineAllowance
// note: transfers must occur using token native precision
IERC20(collateralAddr).safeTransferFrom(msg.sender, address(this), amount);
// emit an event
emit LogDepositCreditForMarket(msg.sender, marketId, collateralAddr, amount);
}

Credit Deposits are incremented when the perp engine deposits a non-usdc or usd token of engine to the market making engine. The total market debt is calculated using this function:

function getTotalDebt(Data storage self) internal view returns (SD59x18 totalDebtUsdX18) {
totalDebtUsdX18 = getUnrealizedDebtUsd(self).add(getRealizedDebtUsd(self));
}

Market:;getrealizeddebt is calculated with:

/// @return realizedDebtUsdX18 The market's net realized debt in USD as SD59x18.
function getRealizedDebtUsd(Data storage self) internal view returns (SD59x18 realizedDebtUsdX18) {
// prepare the credit deposits usd value variable;
UD60x18 creditDepositsValueUsdX18;
// if the credit deposits usd value cache is up to date, return the stored value
if (block.timestamp <= self.lastCreditDepositsValueRehydration) {
creditDepositsValueUsdX18 = ud60x18(self.creditDepositsValueCacheUsd);
} else {
// otherwise, we'll need to loop over credit deposits to calculate it
creditDepositsValueUsdX18 = getCreditDepositsValueUsd(self);
}
// finally after determining the market's latest credit deposits usd value, sum it with the stored net usd
// token issuance to return the net realized debt usd value
realizedDebtUsdX18 = creditDepositsValueUsdX18.intoSD59x18().add(sd59x18(self.netUsdTokenIssuance));
}

This realized debt calculates how much non-usdc tokens that are in the marketmakingengine contract which is Market::creditDepositsValueUsdX18 and 'adds' it to the Market::netusdtokenissuance. I say add in quotation because Market::netusdtokenissuance will always be negative. Market::netusdtokenissuance is updated whenever perp engine sends usd token of engine back to the marketmakingengine contract using CreditDelegationBranch::depositCreditForMarket. if usd token of engine is sent back to the contract, then that means that there is less usdz that zaros has to redeem 1:1 for usdc. If there is less usd token engine to account for, then zaros has less debt. Using the logic from the protocol, total debt is supposed to be positive if netusdissuance > credit deposits but instead the opposite is the case where market debt is positive when credit deposits > net usd token issuance.

Proof Of Code

function test_debtvaluenonnegativewhencreditdepositsgtnetusdissuance(uint128 vaultId,
uint128 marketId
)
external
{
vm.stopPrank();
//c configure vaults and markets
VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);
vm.assume(fuzzVaultConfig.asset != address(wBtc)); //c to avoid overflow issues
vm.assume(fuzzVaultConfig.asset != address(usdc)); //c to log market debt
PerpMarketCreditConfig memory fuzzMarketConfig = getFuzzPerpMarketCreditConfig(marketId);
uint256[] memory marketIds = new uint256[](1);
marketIds[0] = fuzzMarketConfig.marketId;
uint256[] memory vaultIds = new uint256[](1);
vaultIds[0] = fuzzVaultConfig.vaultId;
vm.prank(users.owner.account);
marketMakingEngine.connectVaultsAndMarkets(marketIds, vaultIds);
address engine = marketMakingEngine.workaround_getMarketEngine(fuzzMarketConfig.marketId);
address usdtoken = marketMakingEngine.workaround_getUsdTokenOfEngine(engine);
//c perp engine deposits credit into market to incur debt
deal(fuzzVaultConfig.asset, address(fuzzMarketConfig.engine), 100e18);
deal(usdtoken, address(fuzzMarketConfig.engine), 100e18);
vm.startPrank(address(fuzzMarketConfig.engine));
marketMakingEngine.depositCreditForMarket(fuzzMarketConfig.marketId, fuzzVaultConfig.asset, 10e18);
//c perp engine now deposits usd token into market but less than credit deposits
marketMakingEngine.depositCreditForMarket(fuzzMarketConfig.marketId, usdtoken, 5e18);
vm.stopPrank();
marketMakingEngine.updateVaultCreditCapacity(fuzzVaultConfig.vaultId);
/*c get total credit deposits of market
add the selector of workaround_getCreditDepositsValueUsd to the marketharness array in TreeProxyUtils.sol and increment the bytes array size by 1 to run this test
*/
uint256 marketcreditdeposit= marketMakingEngine.workaround_getCreditDepositsValueUsd(fuzzMarketConfig.marketId);
console.log(marketcreditdeposit);
//c get net usd token issuance
int128 netusdissuance = marketMakingEngine.workaround_getMarketUsdTokenIssuance(fuzzMarketConfig.marketId);
console.log(netusdissuance);
//c get debt value
SD59x18 debtvalue = marketMakingEngine.workaround_getTotalMarketDebt(fuzzMarketConfig.marketId);
console.log(debtvalue.unwrap());
//c when creditdeposits > netusdissuance, debt is a positive number which breaks protocol logic
assert(debtvalue.unwrap() >0);
}

Impact

Incorrect Debt and Credit Allocation: Instead of market debt being positive when netUsdTokenIssuance > creditDeposits, the current implementation inverts this logic, making credit deposits appear as market debt.
As a result, vaults inaccurately register as being in debt when they should be in credit, and vice versa.

Incorrect Reward Distribution: Since vaults earn rewards based on their credit participation, an incorrect debt calculation could lead to underpayment for vaults that should be in credit and overpayment to vaults that should be in debt.
This weakens the integrity of the reward distribution model, making the system unreliable for liquidity providers.

Tools Used

Manual Review, Foundry

Recommendations

To resolve this issue, the protocol should fix the calculation of market debt to correctly reflect the intended logic as per the protocol specification. The following steps should be implemented:

Correct the Debt Calculation in Market::getRealizedDebtUsd
Update the debt calculation formula so that debt is positive when netUsdTokenIssuance > creditDeposits, not the other way around. The correct formula should be:

// Corrected Debt Calculation:
realizedDebtUsdX18 = sd59x18(self.netUsdTokenIssuance).sub(creditDepositsValueUsdX18.intoSD59x18());

Since netUsdTokenIssuance represents debt issuance, it should be positive when debt is high.creditDepositsValueUsdX18 represents assets held, which reduces the market’s net debt.

Modify the CreditDelegationBranch::depositCreditForMarket function so that USDC deposits correctly reduce debt and credit deposits increase the credit balance and do not incorrectly inflate the market’s debt.

Updates

Lead Judging Commences

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

Market::getRealizedDebtUsd incorrectly adds creditDeposits to netUsdTokenIssuance when calculating debt, causing accounting errors because credit deposits should reduce debt

Support

FAQs

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