Part 2

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

Stale Debt Calculations Lead to Incorrect Swap Amounts in `StabilityBranch.fulfillSwap`

Summary

The stability mechanism contains a critical flaw where swap fulfillment calculations use outdated vault debt values, enabling improper asset transfers. The root cause is the missing synchronization between debt state updates (Vault.recalculateVaultsCreditCapacity) and swap execution (StabilityBranch.fulfillSwap). This allows swaps to be processed with stale debt ratios, corrupting the premium/discount calculations that determine USDz redemption rates. The protocol faces direct financial leakage risks as users could receive incorrect asset amounts during debt state transitions, potentially leading to protocol insolvency if exploited during market stress conditions.

Vulnerability Details

The vulnerability stems from using stale debt values during swap fulfillment calculations in the stability mechanism. The critical issue occurs in StabilityBranch.fulfillSwap where asset transfers are calculated using potentially outdated debt values from the vault:

function fulfillSwap(
address user,
uint128 requestId,
bytes calldata priceData,
address engine
)
external
onlyRegisteredSystemKeepers
{
// ...
// get amount out asset
ctx.amountIn = request.amountIn;
@> ctx.amountOutBeforeFeesX18 = getAmountOfAssetOut(ctx.vaultId, ud60x18(ctx.amountIn), ctx.priceX18);
// gets the base fee and swap fee for the given amount out before fees
(ctx.baseFeeX18, ctx.swapFeeX18) = getFeesForAssetsAmountOut(ctx.amountOutBeforeFeesX18, ctx.priceX18);
// ...
// subtract the fees and convert the UD60x18 value to the collateral's decimals value
@> ctx.amountOut =
collateral.convertUd60x18ToTokenAmount(ctx.amountOutBeforeFeesX18.sub(ctx.baseFeeX18.add(ctx.swapFeeX18)));
// ...
// calculates the protocol's share of the swap fee by multiplying the total swap fee by the protocol's fee
// recipients' share.
ctx.protocolSwapFeeX18 = ctx.swapFeeX18.mul(ud60x18(marketMakingEngineConfiguration.totalFeeRecipientsShares));
// the protocol reward amount is the sum of the base fee and the protocol's share of the swap fee
ctx.protocolReward = collateral.convertUd60x18ToTokenAmount(ctx.baseFeeX18.add(ctx.protocolSwapFeeX18));
// ...
// transfer the required assets from the vault to the mm engine contract before distributions
// note: as the swap fee stays in the ZLP Vault, it is technically a net gain to share holders, i.e it is auto
// accumulated to the contract
@> IERC20(ctx.asset).safeTransferFrom(vault.indexToken, address(this), ctx.amountOut + ctx.protocolReward);
// ...
// transfers the remaining amount out to the user, discounting fees
// note: the vault's share of the swap fee remains in the index token contract, thus, we don't need transfer
// it anywhere. The end result is that vaults have an amount of their debt paid off with a discount.
@> IERC20(ctx.asset).safeTransfer(user, ctx.amountOut);
// ...
}

The calculation flow:

1.StabilityBranch.getAmountOfAssetOut uses Vault.getTotalDebt:

// we use the vault's net sum of all debt types coming from its connected markets to determine the swap rate
@> SD59x18 vaultDebtUsdX18 = vault.getTotalDebt();
// calculate the premium or discount that may be applied to the vault asset's index price
// note: if no premium or discount needs to be applied, the premiumDiscountFactorX18 will be
// 1e18 (UD60x18 one value)
UD60x18 premiumDiscountFactorX18 =
UsdTokenSwapConfig.load().getPremiumDiscountFactor(vaultAssetsUsdX18, vaultDebtUsdX18);
// get amounts out taking into consideration the CL price and the premium/discount
amountOutX18 = usdAmountInX18.div(indexPriceX18).mul(premiumDiscountFactorX18);

2.Vault.getTotalDebt aggregates the storage states:

  • marketsRealizedDebtUsd

  • depositedUsdc

  • marketsUnrealizedDebtUsd

function getTotalDebt(Data storage self) internal view returns (SD59x18 totalDebtUsdX18) {
totalDebtUsdX18 = getUnsettledRealizedDebt(self).add(sd59x18(self.marketsUnrealizedDebtUsd));
}
function getUnsettledRealizedDebt(Data storage self)
internal
view
returns (SD59x18 unsettledRealizedDebtUsdX18)
{
unsettledRealizedDebtUsdX18 =
sd59x18(self.marketsRealizedDebtUsd).add(unary(ud60x18(self.depositedUsdc).intoSD59x18()));
}

3.These debt values are only updated in Vault.recalculateVaultsCreditCapacity:

// iterate over each connected market id and distribute its debt so we can have the latest credit
// delegation of the vault id being iterated to the provided `marketId`
(
uint128[] memory updatedConnectedMarketsIdsCache,
SD59x18 vaultTotalRealizedDebtChangeUsdX18,
SD59x18 vaultTotalUnrealizedDebtChangeUsdX18,
UD60x18 vaultTotalUsdcCreditChangeX18,
UD60x18 vaultTotalWethRewardChangeX18
) = _recalculateConnectedMarketsState(self, connectedMarketsIdsCache, true);
// gas optimization: only write to storage if values have changed
//
// updates the vault's stored unsettled realized debt distributed from markets
if (!vaultTotalRealizedDebtChangeUsdX18.isZero()) {
@> self.marketsRealizedDebtUsd = sd59x18(self.marketsRealizedDebtUsd).add(
vaultTotalRealizedDebtChangeUsdX18
).intoInt256().toInt128();
}
// updates the vault's stored unrealized debt distributed from markets
if (!vaultTotalUnrealizedDebtChangeUsdX18.isZero()) {
@> self.marketsUnrealizedDebtUsd = sd59x18(self.marketsUnrealizedDebtUsd).add(
vaultTotalUnrealizedDebtChangeUsdX18
).intoInt256().toInt128();
}
// adds the vault's total USDC credit change, earned from its connected markets, to the
// `depositedUsdc` variable
if (!vaultTotalUsdcCreditChangeX18.isZero()) {
@> self.depositedUsdc = ud60x18(self.depositedUsdc).add(vaultTotalUsdcCreditChangeX18).intoUint128();
}
// distributes the vault's total WETH reward change, earned from its connected markets
if (!vaultTotalWethRewardChangeX18.isZero() && self.wethRewardDistribution.totalShares != 0) {
SD59x18 vaultTotalWethRewardChangeSD59X18 =
sd59x18(int256(vaultTotalWethRewardChangeX18.intoUint256()));
self.wethRewardDistribution.distributeValue(vaultTotalWethRewardChangeSD59X18);
}

4.The swap execution path lacks a call to update these debt values before using them for premium/discount calculations

This creates a race condition where:

  • Debt values become stale between market state changes and swap executions

  • Premium/discount factors applied to swaps are calculated using outdated financial data

  • Asset transfers could over-compensate users or under-protect protocol reserves

The vulnerability is particularly dangerous during periods of high volatility or frequent market operations, where debt values might change significantly between recalculations.

Impact

The stale debt calculation vulnerability creates critical financial risks for the protocol:

  1. Inaccurate Premium/Discount Application

    • Outdated debt values lead to incorrect premium/discount factors in swap rate calculations

    • Users could receive up to 100% of asset value during protocol insolvency states

    • Protocol might over-pay during premium phases (user profit) or under-pay during discount phases (protocol profit)

  2. Arbitrage Opportunities

    • Sophisticated actors could front-run recalculations to exploit stale pricing

    • Creates risk-free profit opportunities at protocol expense during debt state transitions

  3. Protocol Insolvency Risk

    • If unrealized debt grows without recalculation:

    • Swaps could drain collateral reserves beyond sustainable levels

    • Vaults might become undercollateralized without proper accounting

  4. Systemic Pricing Inaccuracy

    • All USDz swaps would reference outdated debt ratios

    • Destabilizes the protocol's core stability mechanism

    • Compromises the 1:1 USDz peg maintenance capability

The combination of these factors creates a high-severity risk of direct financial losses and protocol insolvency, particularly during periods of market stress or high swap volume.

Tools Used

Manual Review

Recommendations

Implement debt state synchronization before swap processing by adding the recalculation hook:

function fulfillSwap(...) {
// ...
// Before debt calculations
Vault.recalculateVaultsCreditCapacity(vaultId);
// get amount out asset
ctx.amountIn = request.amountIn;
ctx.amountOutBeforeFeesX18 = getAmountOfAssetOut(ctx.vaultId, ud60x18(ctx.amountIn), ctx.priceX18);
// ...
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
4 months ago
inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

elvin_a_block Submitter
4 months ago
inallhonesty Lead Judge
4 months ago
inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

fulfillSwap should call recalculateVaultsCreditCapacity

Support

FAQs

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