Part 2

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

Stale Debt State Causes Inaccurate Swap Pricing in `StabilityBranch.initiateSwap`

Summary

The swap pricing mechanism in StabilityBranch.initiateSwap uses outdated debt calculations due to missing state updates, allowing swaps to execute against obsolete financial positions. This occurs because:

  • Swap rate calculations depend on vault debt state updated via Vault.recalculateVaultsCreditCapacity

  • No enforcement exists to ensure fresh debt data before swap execution

  • Slippage checks and balance validations operate on stale values

This mismatch between reported and actual vault state creates pricing errors that could lead to direct financial losses for users and protocol insolvency risks.

Vulnerability Details

The swap rate calculation in StabilityBranch.initiateSwap (StabilityBranch.sol#L254-L255) relies on debt state values (marketsRealizedDebtUsd, depositedUsdc, marketsUnrealizedDebtUsd) that become stale if not updated via Vault.recalculateVaultsCreditCapacity:

// StabilityBranch.initiateSwap()
ctx.expectedAssetOut =
@> getAmountOfAssetOut(vaultIds[i], ud60x18(amountsIn[i]), ctx.collateralPriceX18).intoUint256();
// revert if the slippage wouldn't pass or the expected output was 0
if (ctx.expectedAssetOut == 0) revert Errors.ZeroOutputTokens();
if (ctx.expectedAssetOut < minAmountsOut[i]) {
revert Errors.SlippageCheckFailed(minAmountsOut[i], ctx.expectedAssetOut);
}
// if there aren't enough assets in the vault to fulfill the swap request, we must revert
if (ctx.vaultAssetBalance < ctx.expectedAssetOut) {
revert Errors.InsufficientVaultBalance(vaultIds[i], ctx.vaultAssetBalance, ctx.expectedAssetOut);
}
// StabilityBranch.getAmountOfAssetOut()
// 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);
// Vault.getTotalDebt()
function getTotalDebt(Data storage self) internal view returns (SD59x18 totalDebtUsdX18) {
totalDebtUsdX18 = getUnsettledRealizedDebt(self).add(sd59x18(self.marketsUnrealizedDebtUsd));
}
// Vault.getUnsettledRealizedDebt
function getUnsettledRealizedDebt(Data storage self)
internal
view
returns (SD59x18 unsettledRealizedDebtUsdX18)
{
unsettledRealizedDebtUsdX18 =
sd59x18(self.marketsRealizedDebtUsd).add(unary(ud60x18(self.depositedUsdc).intoSD59x18()));
}

the function Vault.recalculateVaultsCreditCapacity is critical for:

  1. Processing market debt distributions

  2. Updating deposited USDC balances

  3. Resetting unrealized debt tracking

// 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);
}

Without calling this function prior to swap initiation:

  • Debt calculations in Vault.getTotalDebt use outdated values

  • Premium/discount factors in StabilityBranch.getAmountOfAssetOut become inaccurate

  • Slippage checks and vault balance validations operate on incorrect assumptions

This creates protocol instability where:

  • Valid swaps might revert due to miscalculated slippage thresholds

  • Invalid swaps could pass checks using stale debt data

  • Asset pricing diverges from actual vault collateralization state

The missing state update call creates a time window where swap operations execute against obsolete financial positions, violating core protocol accounting assumptions.

Impact

The use of stale debt data in swap rate calculations leads to three primary risks:

  1. Financial Losses for Users
    Traders may receive incorrect swap rates resulting in:

    • Overpayment when buying vault assets

    • Underpayment when selling USDz tokens

    • Slippage protections failing to prevent unfavorable trades

  2. Vault Insolvency Risk
    Outdated debt tracking could allow:

    • Swaps to drain undercollateralized vaults

    • Negative equity positions going undetected

    • Protocol-wide contagion through interconnected markets

  3. Protocol Accounting Corruption
    Persistent state inconsistencies may:

    • Break USDz peg maintenance mechanisms

    • Distribute incorrect WETH rewards to LPs

    • Cause cascading failures in auto-deleveraging systems

These impacts directly violate core protocol guarantees of fair pricing and collateral-backed swaps, creating systemic risk for all participants.

Tools Used

Manual Review

Recommendations

Implement debt state synchronization before swap processing:

  1. Add Pre-Swap Recalculation
    Call Vault.recalculateVaultsCreditCapacity in StabilityBranch.initiateSwap before accessing debt data:

    for (uint256 i; i < vaultIds.length; i++) {
    Vault.Data storage vault = Vault.load(vaultIds[i]);
    vault.recalculateVaultsCreditCapacity(); // Add this line
    // Existing swap logic...
    }
  2. Implement State Freshness Checks
    Add timestamp-based validation in Vault.getTotalDebt (Vault.sol#LXXX-LXXX):

    require(block.timestamp - lastUpdateTimestamp < MAX_STALE_TIME, "Debt data stale");
  3. Automate Debt Updates
    Integrate with Chainlink Automation to trigger regular recalculations:

    • Schedule periodic updates (e.g., hourly)

    • Trigger on significant price movements

    • Activate before large swap operations

These changes ensure swap calculations always use current debt data, maintaining protocol accounting integrity and protecting user funds.

Updates

Lead Judging Commences

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

Support

FAQs

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