Summary
Due to an overly aggressive guard clause in Market::getVaultAccumulatedValues, creditdelegationbranch::lastVaultDistributedRealizedDebtUsdPerShare is never updated from its initial zero value. As a result, subsequent accumulation events always calculate a zero change, leading to incorrect debt distributions across connected markets.
Vulnerability Details
A key function in the zaros protocol is Vault::recalculateVaultsCreditCapacity. This function is used to update the vaults credit capacity which updates a host of state variables that are key for other functions to calculate accurately. See below:
function recalculateVaultsCreditCapacity(uint256[] memory vaultsIds) internal {
for (uint256 i; i < vaultsIds.length; i++) {
uint128 vaultId = vaultsIds[i].toUint128();
Data storage self = load(vaultId);
uint256 connectedMarketsConfigLength = self.connectedMarkets.length;
if (connectedMarketsConfigLength == 0) continue;
EnumerableSet.UintSet storage connectedMarkets = self.connectedMarkets[connectedMarketsConfigLength - 1];
uint128[] memory connectedMarketsIdsCache = new uint128[](connectedMarkets.length());
updateVaultAndCreditDelegationWeight(self, connectedMarketsIdsCache);
(
uint128[] memory updatedConnectedMarketsIdsCache,
SD59x18 vaultTotalRealizedDebtChangeUsdX18,
SD59x18 vaultTotalUnrealizedDebtChangeUsdX18,
UD60x18 vaultTotalUsdcCreditChangeX18,
UD60x18 vaultTotalWethRewardChangeX18
) = _recalculateConnectedMarketsState(self, connectedMarketsIdsCache, true);
if (!vaultTotalRealizedDebtChangeUsdX18.isZero()) {
self.marketsRealizedDebtUsd = sd59x18(self.marketsRealizedDebtUsd).add(
vaultTotalRealizedDebtChangeUsdX18
).intoInt256().toInt128();
}
if (!vaultTotalUnrealizedDebtChangeUsdX18.isZero()) {
self.marketsUnrealizedDebtUsd = sd59x18(self.marketsUnrealizedDebtUsd).add(
vaultTotalUnrealizedDebtChangeUsdX18
).intoInt256().toInt128();
}
if (!vaultTotalUsdcCreditChangeX18.isZero()) {
self.depositedUsdc = ud60x18(self.depositedUsdc).add(vaultTotalUsdcCreditChangeX18).intoUint128();
}
if (!vaultTotalWethRewardChangeX18.isZero() && self.wethRewardDistribution.totalShares != 0) {
SD59x18 vaultTotalWethRewardChangeSD59X18 =
sd59x18(int256(vaultTotalWethRewardChangeX18.intoUint256()));
self.wethRewardDistribution.distributeValue(vaultTotalWethRewardChangeSD59X18);
}
(, SD59x18 vaultNewCreditCapacityUsdX18) =
_updateCreditDelegations(self, updatedConnectedMarketsIdsCache, false);
emit LogUpdateVaultCreditCapacity(
vaultId,
vaultTotalRealizedDebtChangeUsdX18.intoInt256(),
vaultTotalUnrealizedDebtChangeUsdX18.intoInt256(),
vaultTotalUsdcCreditChangeX18.intoUint256(),
vaultTotalWethRewardChangeX18.intoUint256(),
vaultNewCreditCapacityUsdX18.intoInt256()
);
}
}
This function calls Market::getVaultAccumulatedValues. See below:
function getVaultAccumulatedValues(
Data storage self,
UD60x18 vaultDelegatedCreditUsdX18,
SD59x18 lastVaultDistributedRealizedDebtUsdPerShareX18,
SD59x18 lastVaultDistributedUnrealizedDebtUsdPerShareX18,
UD60x18 lastVaultDistributedUsdcCreditPerShareX18,
UD60x18 lastVaultDistributedWethRewardPerShareX18
)
internal
view
returns (
SD59x18 realizedDebtChangeUsdX18,
SD59x18 unrealizedDebtChangeUsdX18,
UD60x18 usdcCreditChangeX18,
UD60x18 wethRewardChangeX18
)
{
UD60x18 vaultCreditShareX18 = vaultDelegatedCreditUsdX18.div(getTotalDelegatedCreditUsd(self));
realizedDebtChangeUsdX18 = !lastVaultDistributedRealizedDebtUsdPerShareX18.isZero()
? sd59x18(self.realizedDebtUsdPerVaultShare).sub(lastVaultDistributedRealizedDebtUsdPerShareX18).mul(
vaultCreditShareX18.intoSD59x18()
)
: SD59x18_ZERO;
unrealizedDebtChangeUsdX18 = !lastVaultDistributedUnrealizedDebtUsdPerShareX18.isZero()
? sd59x18(self.unrealizedDebtUsdPerVaultShare).sub(lastVaultDistributedUnrealizedDebtUsdPerShareX18).mul(
vaultCreditShareX18.intoSD59x18()
)
: SD59x18_ZERO;
usdcCreditChangeX18 = !lastVaultDistributedUsdcCreditPerShareX18.isZero()
? ud60x18(self.usdcCreditPerVaultShare).sub(lastVaultDistributedUsdcCreditPerShareX18).mul(
vaultCreditShareX18
)
: UD60x18_ZERO;
wethRewardChangeX18 = ud60x18(self.wethRewardPerVaultShare).sub(lastVaultDistributedWethRewardPerShareX18);
}
The bug occurs for 2 reason. Market::getVaultAccumulatedValues has the following line:
realizedDebtChangeUsdX18 = !lastVaultDistributedRealizedDebtUsdPerShareX18.isZero()
? sd59x18(self.realizedDebtUsdPerVaultShare).sub(lastVaultDistributedRealizedDebtUsdPerShareX18).mul(
vaultCreditShareX18.intoSD59x18()
)
: SD59x18_ZERO;
This checks if lastVaultDistributedRealizedDebtUsdPerShareX18 is zero and, if so, returns a zero change for the realized debt calculation. The initial state of lastVaultDistributedRealizedDebtUsdPerShare is zero and so when Vault::recalculateVaultsCreditCapacity is first called, this value returns 0.
However, Vault::_recalculateConnectedMarketsState has the following code block:
if (
ctx.realizedDebtChangeUsdX18.isZero() && ctx.unrealizedDebtChangeUsdX18.isZero()
&& ctx.usdcCreditChangeX18.isZero() && ctx.wethRewardChangeX18.isZero()
) {
continue;
}
vaultTotalRealizedDebtChangeUsdX18 = vaultTotalRealizedDebtChangeUsdX18.add(ctx.realizedDebtChangeUsdX18);
vaultTotalUnrealizedDebtChangeUsdX18 =
vaultTotalUnrealizedDebtChangeUsdX18.add(ctx.unrealizedDebtChangeUsdX18);
vaultTotalUsdcCreditChangeX18 = vaultTotalUsdcCreditChangeX18.add(ctx.usdcCreditChangeX18);
vaultTotalWethRewardChangeX18 = vaultTotalWethRewardChangeX18.add(ctx.wethRewardChangeX18);
creditDelegation.updateVaultLastDistributedValues(
sd59x18(market.realizedDebtUsdPerVaultShare),
sd59x18(market.unrealizedDebtUsdPerVaultShare),
ud60x18(market.usdcCreditPerVaultShare),
ud60x18(market.wethRewardPerVaultShare)
);
}
}
This checks if ctx.realizedDebtChangeUsdX18.isZero() && ctx.unrealizedDebtChangeUsdX18.isZero()&& ctx.usdcCreditChangeX18.isZero() && ctx.wethRewardChangeX18.isZero() are all zero which in the first instance they will be due to the condition in Market::getVaultAccumulatedValues. In this case, it skips the iteration. By skipping the iteration, it misses out on a key function call which is creditDelegation.updateVaultLastDistributedValues. This function updates all state variables in creditdelegation.data with their appropriate data especially with updating market.realizedDebtUsdPerVaultShare which is used to update lastVaultDistributedRealizedDebtUsdPerShareX18 in the function.
Since this value is not updated in creditdelegation.data, lastVaultDistributedRealizedDebtUsdPerShareX18 will remain 0 whenever Vault::recalculateVaultsCreditCapacity is next called. In fact, no matter how many times it is called, lastVaultDistributedRealizedDebtUsdPerShareX18 will be 0. This has additional effects as what this means is that whenever creditdelegationbranch::settlevaultsdeposit is called to settle vaults debt, the vaults debt will always be zero. The key line in the function is:
ctx.vaultUnsettledRealizedDebtUsdX18 = vault.getUnsettledRealizedDebt();
See vault.getUnsettledRealizedDebt:
function getUnsettledRealizedDebt(Data storage self)
internal
view
returns (SD59x18 unsettledRealizedDebtUsdX18)
{
unsettledRealizedDebtUsdX18 =
sd59x18(self.marketsRealizedDebtUsd).add(unary(ud60x18(self.depositedUsdc).intoSD59x18()));
}
Vault::marketsRealizedDebtUsd is calculated with the following line in Vault::recalculateVaultsCreditCapacity:
if (!vaultTotalRealizedDebtChangeUsdX18.isZero()) {
self.marketsRealizedDebtUsd = sd59x18(self.marketsRealizedDebtUsd).add(
vaultTotalRealizedDebtChangeUsdX18
).intoInt256().toInt128();
vaultTotalRealizedDebtChangeUsdX18 is calculated from the following line in Vault::_recalculateconnectedmarketsstate:
vaultTotalRealizedDebtChangeUsdX18 = vaultTotalRealizedDebtChangeUsdX18.add(ctx.realizedDebtChangeUsdX18);
and finally, realizedDebtChangeUsdX18 is calculated in Market::getVaultAccumulatedValues as follows:
realizedDebtChangeUsdX18 = !lastVaultDistributedRealizedDebtUsdPerShareX18.isZero()
? sd59x18(self.realizedDebtUsdPerVaultShare).sub(lastVaultDistributedRealizedDebtUsdPerShareX18).mul(
vaultCreditShareX18.intoSD59x18()
)
: SD59x18_ZERO;
This is how the vault debt maps to Market::realizedDebtUsdPerVaultShare which is the variable we identified that was never updated from 0. As a result, the vault debt is never updated from 0 so whenever creditdelegationbranch::settlevaultsdebt is called, the vault debt will always be 0 even when the vault should be allocated debt.
Proof Of Code (POC)
function test_vaultdebtisneverupdated(
uint128 vaultId,
uint128 assetsToDeposit,
uint128 marketId
)
external
{
vm.stopPrank();
VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);
vm.assume(fuzzVaultConfig.asset != address(usdc));
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 userA = users.naruto.account;
assetsToDeposit = 1e14;
fundUserAndDepositInVault(userA, fuzzVaultConfig.vaultId, assetsToDeposit);
deal(fuzzVaultConfig.asset, userA, 100e18);
vm.prank(userA);
marketMakingEngine.deposit(fuzzVaultConfig.vaultId, assetsToDeposit, 0, "", false);
deal(fuzzVaultConfig.asset, address(fuzzMarketConfig.engine), 100e18);
vm.prank(address(fuzzMarketConfig.engine));
marketMakingEngine.depositCreditForMarket(fuzzMarketConfig.marketId, fuzzVaultConfig.asset, fuzzVaultConfig.depositCap*2);
address userB = users.sasuke.account;
deal(fuzzVaultConfig.asset, userB, 100e18);
vm.startPrank(userB);
marketMakingEngine.deposit(fuzzVaultConfig.vaultId, assetsToDeposit, 0, "", false);
vm.stopPrank();
SD59x18 totalmarketdebt = marketMakingEngine.workaround_getTotalMarketDebt(fuzzMarketConfig.marketId);
console.log(totalmarketdebt.unwrap());
int128 realizeddebtpervaultshare = marketMakingEngine.workaround_getrealizedDebtUsdPerVaultShare(fuzzMarketConfig.marketId);
console.log(realizeddebtpervaultshare);
int128 lastvaultdistributedrealizeddebtusdpershare = marketMakingEngine.workaround_CreditDelegation_getlastVaultDistributedRealizedDebtUsdPerShare(fuzzVaultConfig.vaultId, fuzzMarketConfig.marketId);
console.log(lastvaultdistributedrealizeddebtusdpershare);
assert (realizeddebtpervaultshare != lastvaultdistributedrealizeddebtusdpershare);
vm.prank(address(perpsEngine));
marketMakingEngine.settleVaultsDebt(vaultIds);
int128 vaultdebt = marketMakingEngine.workaround_getVaultDebt(fuzzVaultConfig.vaultId);
console.log(vaultdebt);
assertEq(vaultdebt, 0);
}
OPTIONAL ADDON THAT MAY BE NEEDED IF RUNNING INTO WORKAROUND ERRORS WHEN RUNNING POC
Note that I added the following workarounds to VaultHarness.sol and MarketHarness.sol to get values I needed and I may have used them for POC's so if some of the tests do not work due to workaround functions not being found, add the following functions to VaultHarness.sol:
function workaround_CreditDelegation_getweight(uint128 vaultId, uint128 marketId) external view returns (uint128) {
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, marketId);
return creditDelegation.weight;
}
function workaround_Vault_getTotalCreditDelegationWeight(
uint128 vaultId
)
external view returns (uint128)
{
Vault.Data storage vaultData = Vault.load(vaultId);
return vaultData.totalCreditDelegationWeight ;
}
function workaround_CreditDelegation_getlastVaultDistributedRealizedDebtUsdPerShare(uint128 vaultId, uint128 marketId) external view returns (int128) {
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, marketId);
return creditDelegation.lastVaultDistributedRealizedDebtUsdPerShare;}
function workaround_CreditDelegation_setvalueUsd(uint128 vaultId, uint128 marketId, uint128 valueUsd) external {
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, marketId);
creditDelegation.valueUsd = valueUsd;
}
function workaround_CreditDelegation_getlastVaultDistributedUnrealizedDebtUsdPerShare(uint128 vaultId, uint128 marketId) external view returns (int128) {
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, marketId);
return creditDelegation.lastVaultDistributedUnrealizedDebtUsdPerShare;}
function workaround_CreditDelegation_getlastVaultDistributedUsdcCreditPerShare(uint128 vaultId, uint128 marketId) external view returns (uint128) {
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, marketId);
return creditDelegation.lastVaultDistributedUsdcCreditPerShare;}
function workaround_CreditDelegation_getlastVaultDistributedWethRewardPerShare(uint128 vaultId, uint128 marketId) external view returns (uint128) {
CreditDelegation.Data storage creditDelegation = CreditDelegation.load(vaultId, marketId);
return creditDelegation.lastVaultDistributedWethRewardPerShare;}
Add the following functions to MarketHarness.sol:
function workaround_gettotalWethReward(uint128 marketId) external view returns (uint256) {
Market.Data storage market = Market.load(marketId);
return market.wethRewardPerVaultShare;
}
function workaround_getrealizedDebtUsdPerVaultShare(uint128 marketId) external view returns (int128) {
Market.Data storage market = Market.load(marketId);
return market.realizedDebtUsdPerVaultShare;
}
After registering the selectors of these functions in TreeProxyUtils.sol and increasing the bytes array size, it should work as expected and return the correct values
Impact
Due to the guard clause preventing updates to the credit delegation branch's state, the vault's debt accumulation mechanism fails to capture any changes from connected markets. This results in:
Inaccurate Debt Accounting: The lastVaultDistributedRealizedDebtUsdPerShare remains at its initial zero value, causing the calculated debt changes to always be zero. As a consequence, the vault’s total debt never updates to reflect the actual market conditions.
Settlement Failures: When the system attempts to settle the vault’s debt (via functions such as settlevaultsdebt), it relies on these accumulated values. With the debt always recorded as zero, the settlement process will be flawed, potentially causing imbalances in user accounts and misallocations of funds.
Tools Used
Manual Review, Foundry
Recommendations
Remove the Guard Clause in Vault::recalculateVaultsCreditCapacity:
The root cause of the issue is the following code block:
if (
ctx.realizedDebtChangeUsdX18.isZero() && ctx.unrealizedDebtChangeUsdX18.isZero()
&& ctx.usdcCreditChangeX18.isZero() && ctx.wethRewardChangeX18.isZero()
) {
continue;
}
Removing this block will prevent the function from skipping the update to creditDelegation.updateVaultLastDistributedValues. This change will allow the per-share debt values (and other related metrics) to be updated correctly, even when the computed change appears to be zero on the first accumulation event.
In place of the removed guard clause, introduce logic that explicitly compares the current market debt values (as obtained by market.getRealizedDebtUsd() and market.getUnrealizedDebtUsd()) with their last distributed counterparts stored in the credit delegation. Update the credit delegation only if there is a meaningful change. For example, add a conditional check such as:
bool hasDebtChanged =
!market.getRealizedDebtUsd().eq(creditDelegation.lastVaultDistributedRealizedDebtUsdPerShare) ||
!market.getUnrealizedDebtUsd().eq(creditDelegation.lastVaultDistributedUnrealizedDebtUsdPerShare);
if (!hasDebtChanged) {
continue;
}
This delta check must be implemented so that updates are only skipped when there is absolutely no change in the market’s debt values relative to the last distributed values, ensuring that even an initial update (where the values might be zero) is correctly recorded and also preventing the same debt being redistributed to vaults everytime Vault:recalculatevaultscreditcapacity is called.
Review Initialization of Last Distributed Values: Evaluate the initialization logic for lastVaultDistributedRealizedDebtUsdPerShare (and the corresponding variables for unrealized debt, USDC credit, and WETH reward). Ensure that the first accumulation event correctly captures and sets these values so that subsequent changes can be computed accurately.