Part 2

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

Wrong Implementation of `settleVaultsDebt` and `rebalanceVaultsAssets` Functions Leading to Incorrect Vault Debt States

Summary

A critical implementation flaw in the debt settlement and rebalancing logic causes vaults' debt-credit states to progressively become more imbalanced, potentially leading to protocol instability.

Links to affected code

  • CreditDelegationBranch.sol:L408~535,593~686,727~798

Vulnerability details

Finding description and impact

The issue stems from incorrect logic in determining vault credit/debt states in CreditDelegationBranch.sol. According to the documentation and implementation in Vault.sol, a vault's unsettled realized debt is calculated as realized debt - deposited usdc.
CreditDelegationBranch.sol:L567~592.

/// @notice Rebalances credit and debt between two vaults.
/// @dev There are multiple factors that may result on vaults backing the same engine having a completely
/// different credit or debt state, such as:
/// - connecting vaults with markets in different times
/// - connecting vaults with different sets of markets
/// - users swapping the engine's usd token for assets of different vaults
/// This way, from time to time, the system keepers must rebalance vaults with a significant state difference in
/// order to facilitate settlement of their credit and debt. A rebalancing doesn't need to always fully settle the
/// amount of USDC that a vault in credit requires to settle its due amount, so the system is optimized to ensure
/// a financial stability of the protocol.
/// @dev Example:
-> /// in credit vault markets realized debt = -100 -> -90
-> /// in credit vault deposited usdc = 200 -> 210
-> /// in credit vault unsettled realized debt = -300 | as -100 + -200 -> after settlement -> -300 | as -90 + -210
/// = -300
/// thus, we need to rebalance as the in credit vault doesn't own enough usdc to settle its due credit
-> /// in debt vault markets realized debt = 50 -> 40
-> /// in debt vault deposited usdc = 10 -> 0
-> /// in debt vault unsettled realized debt = 40 | as 50 + -10 -> after settlement -> 40 | as 40 + 0 = 40
/// @dev The first vault id passed is assumed to be the in credit vault, and the second vault id is assumed to be
/// the in debt vault.
/// @dev The final unsettled realized debt of both vaults MUST remain the same after the rebalance.
/// @dev The actual increase or decrease in the vaults' unsettled realized debt happen at `settleVaultsDebt`.
/// @param vaultsIds The vaults' identifiers to rebalance.

The core problem lies in how vault states are interpreted:

  1. When unsettledRealizedDebt < 0, the vault is in credit

  2. When unsettledRealizedDebt > 0, the vault is in debt

However, the implementation in settleVaultsDebt and rebalanceVaultsAssets incorrectly reverses this logic, causing:

  • Wrong settlement direction for vault debts

  • Increasing imbalances between realized debt and deposited USDC

  • Progressive deterioration of vault states

The same logical error exists in rebalanceVaultsAssets where debt/credit state checks are reversed, further compounding the issue.
As we can see above, in credit valut unsettled realized debt is less than zero and in debt vault unsettled realized debt is bigger than zero.
In fact, Vault.sol#getUnsettledRealizedDebt() function is as follows.

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

From above code snippet, we can see that unsettled realized debt is realized debt - deposited usdc.

But CreditDelegationBranch.sol#settleVaultsDebt() is as follows.

function settleVaultsDebt(uint256[] calldata vaultsIds) external onlyRegisteredSystemKeepers {
// first, we need to update the credit capacity of the vaults
Vault.recalculateVaultsCreditCapacity(vaultsIds);
// working data, cache usdc address
SettleVaultDebtContext memory ctx;
ctx.usdc = MarketMakingEngineConfiguration.load().usdc;
// load the usdc collateral data storage pointer
Collateral.Data storage usdcCollateralConfig = Collateral.load(ctx.usdc);
for (uint256 i; i < vaultsIds.length; i++) {
// load the vault storage pointer
Vault.Data storage vault = Vault.loadExisting(vaultsIds[i].toUint128());
// cache the vault's unsettled debt, if zero skip to next vault
// amount in zaros internal precision
-> ctx.vaultUnsettledRealizedDebtUsdX18 = vault.getUnsettledRealizedDebt();
if (ctx.vaultUnsettledRealizedDebtUsdX18.isZero()) continue;
// otherwise vault has debt to be settled, cache the vault's collateral asset
ctx.vaultAsset = vault.collateral.asset;
// loads the dex swap strategy data storage pointer
DexSwapStrategy.Data storage dexSwapStrategy =
DexSwapStrategy.loadExisting(vault.swapStrategy.assetDexSwapStrategyId);
-> // if the vault is in debt, swap its assets to USDC
L436-> if (ctx.vaultUnsettledRealizedDebtUsdX18.lt(SD59x18_ZERO)) {
// get swap amount; both input and output in native precision
ctx.swapAmount = calculateSwapAmount(
dexSwapStrategy.dexAdapter,
ctx.usdc,
ctx.vaultAsset,
usdcCollateralConfig.convertSd59x18ToTokenAmount(ctx.vaultUnsettledRealizedDebtUsdX18.abs())
);
// swap the vault's assets to usdc in order to cover the usd denominated debt partially or fully
// both input and output in native precision
ctx.usdcOut = _convertAssetsToUsdc(
vault.swapStrategy.usdcDexSwapStrategyId,
ctx.vaultAsset,
ctx.swapAmount,
vault.swapStrategy.usdcDexSwapPath,
address(this),
ctx.usdc
);
// sanity check to ensure we didn't somehow give away the input tokens
if (ctx.usdcOut == 0) revert Errors.ZeroOutputTokens();
// uint256 -> udc60x18 scaling native precision to zaros internal precision
ctx.usdcOutX18 = usdcCollateralConfig.convertTokenAmountToUd60x18(ctx.usdcOut);
// use the amount of usdc bought with assets to update the vault's state
// note: storage updates must be done using zaros internal precision
//
// deduct the amount of usdc swapped for assets from the vault's unsettled debt
vault.marketsRealizedDebtUsd -= ctx.usdcOutX18.intoUint256().toInt256().toInt128();
// allocate the usdc acquired to back the engine's usd token
UsdTokenSwapConfig.load().usdcAvailableForEngine[vault.engine] += ctx.usdcOutX18.intoUint256();
// update the variables to be logged
ctx.assetIn = ctx.vaultAsset;
ctx.assetInAmount = ctx.swapAmount;
ctx.assetOut = ctx.usdc;
ctx.assetOutAmount = ctx.usdcOut;
// since we're handling debt, we provide a positive value
ctx.settledDebt = ctx.usdcOut.toInt256();
} else {
// else vault is in credit, swap its USDC previously accumulated
// from market and vault deposits into its underlying asset
// get swap amount; both input and output in native precision
ctx.usdcIn = calculateSwapAmount(
dexSwapStrategy.dexAdapter,
ctx.vaultAsset,
ctx.usdc,
usdcCollateralConfig.convertSd59x18ToTokenAmount(ctx.vaultUnsettledRealizedDebtUsdX18.abs())
);
// get deposited USDC balance of the vault, convert to native precision
ctx.vaultUsdcBalance = usdcCollateralConfig.convertUd60x18ToTokenAmount(ud60x18(vault.depositedUsdc));
// if the vault doesn't have enough usdc use whatever amount it has
// make sure we compare native precision values together and output native precision
ctx.usdcIn = (ctx.usdcIn <= ctx.vaultUsdcBalance) ? ctx.usdcIn : ctx.vaultUsdcBalance;
// swaps the vault's usdc balance to more vault assets and
// send them to the ZLP Vault contract (index token address)
// both input and output in native precision
ctx.assetOutAmount = _convertUsdcToAssets(
vault.swapStrategy.assetDexSwapStrategyId,
ctx.vaultAsset,
ctx.usdcIn,
vault.swapStrategy.assetDexSwapPath,
vault.indexToken,
ctx.usdc
);
// sanity check to ensure we didn't somehow give away the input tokens
if (ctx.assetOutAmount == 0) revert Errors.ZeroOutputTokens();
// subtract the usdc amount used to buy vault assets from the vault's deposited usdc, thus, settling
// the due credit amount (partially or fully).
// note: storage updates must be done using zaros internal precision
vault.depositedUsdc -= usdcCollateralConfig.convertTokenAmountToUd60x18(ctx.usdcIn).intoUint128();
// update the variables to be logged
ctx.assetIn = ctx.usdc;
ctx.assetInAmount = ctx.usdcIn;
ctx.assetOut = ctx.vaultAsset;
// since we're handling credit, we provide a negative value
ctx.settledDebt = -ctx.usdcIn.toInt256();
}
// emit an event per vault settled
emit LogSettleVaultDebt(
vaultsIds[i].toUint128(),
ctx.assetIn,
ctx.assetInAmount,
ctx.assetOut,
ctx.assetOutAmount,
ctx.settledDebt
);
}
}

On L436, vault debt state is checked incorrectly.
So after settlememt vault's imbalance between realized debt and deposited usdc is increased.

For example:

Initial state:
- Realized debt: 30
- Deposited USDC: 40
- Unsettled realized debt: 30 - 40 = -10 (vault is in credit)
After incorrect settlement:
- Realized debt becomes: 30 - 10 = 20
- Deposited USDC remains: 40
- New imbalance: -20 (doubled from initial -10)

This incorrect consideration exist in CreditDelegationBranch.sol#rebalanceVaultsAssets().

function rebalanceVaultsAssets(uint128[2] calldata vaultsIds) external onlyRegisteredSystemKeepers {
// load the storage pointers of the vaults in net credit and net debt
Vault.Data storage inCreditVault = Vault.loadExisting(vaultsIds[0]);
Vault.Data storage inDebtVault = Vault.loadExisting(vaultsIds[1]);
// both vaults must belong to the same engine in order to have their debt
// state rebalanced, as each usd token's debt is isolated
if (inCreditVault.engine != inDebtVault.engine) {
revert Errors.VaultsConnectedToDifferentEngines();
}
// create an in-memory dynamic array in order to call `Vault::recalculateVaultsCreditCapacity`
uint256[] memory vaultsIdsForRecalculation = new uint256[](2);
vaultsIdsForRecalculation[0] = vaultsIds[0];
vaultsIdsForRecalculation[1] = vaultsIds[1];
// recalculate the credit capacity of both vaults
Vault.recalculateVaultsCreditCapacity(vaultsIdsForRecalculation);
// cache the in debt vault & in credit vault unsettled debt
SD59x18 inDebtVaultUnsettledRealizedDebtUsdX18 = inDebtVault.getUnsettledRealizedDebt();
SD59x18 inCreditVaultUnsettledRealizedDebtUsdX18 = inCreditVault.getUnsettledRealizedDebt();
// revert if 1) the vault that is supposed to be in credit is not OR
// 2) the vault that is supposed to be in debt is not
if (
-> inCreditVaultUnsettledRealizedDebtUsdX18.lte(SD59x18_ZERO)
-> || inDebtVaultUnsettledRealizedDebtUsdX18.gte(SD59x18_ZERO)
) {
revert Errors.InvalidVaultDebtSettlementRequest();
}
// get debt absolute value
SD59x18 inDebtVaultUnsettledRealizedDebtUsdX18Abs = inDebtVaultUnsettledRealizedDebtUsdX18.abs();
// if debt absolute value > credit, use credit value, else use debt value
SD59x18 depositAmountUsdX18 = inCreditVaultUnsettledRealizedDebtUsdX18.gt(
inDebtVaultUnsettledRealizedDebtUsdX18Abs
) ? inDebtVaultUnsettledRealizedDebtUsdX18Abs : inCreditVaultUnsettledRealizedDebtUsdX18;
// loads the dex swap strategy data storage pointer
DexSwapStrategy.Data storage dexSwapStrategy =
DexSwapStrategy.loadExisting(inDebtVault.swapStrategy.usdcDexSwapStrategyId);
// load usdc address
address usdc = MarketMakingEngineConfiguration.load().usdc;
// cache input asset and dex adapter
CalculateSwapContext memory ctx;
ctx.inDebtVaultCollateralAsset = inDebtVault.collateral.asset;
ctx.dexAdapter = dexSwapStrategy.dexAdapter;
// get collateral asset amount in native precision of ctx.inDebtVaultCollateralAsset
uint256 assetInputNative = IDexAdapter(ctx.dexAdapter).getExpectedOutput(
usdc,
ctx.inDebtVaultCollateralAsset,
// convert usdc input to native precision
Collateral.load(usdc).convertSd59x18ToTokenAmount(depositAmountUsdX18)
);
// prepare the data for executing the swap asset -> usdc
SwapExactInputSinglePayload memory swapCallData = SwapExactInputSinglePayload({
tokenIn: ctx.inDebtVaultCollateralAsset,
tokenOut: usdc,
amountIn: assetInputNative,
recipient: address(this) // deposit the usdc to the market making engine proxy
});
// approve the collateral token to the dex adapter and swap assets for USDC
IERC20(ctx.inDebtVaultCollateralAsset).approve(ctx.dexAdapter, assetInputNative);
dexSwapStrategy.executeSwapExactInputSingle(swapCallData);
// SD59x18 -> uint128 using zaros internal precision
uint128 usdDelta = depositAmountUsdX18.intoUint256().toUint128();
// important considerations:
// 1) all subsequent storge updates must use zaros internal precision
// 2) code implicitly assumes that 1 USD = 1 USDC
//
// deposits the USDC to the in-credit vault
inCreditVault.depositedUsdc += usdDelta;
// increase the in-credit vault's share of the markets realized debt
// as it has received the USDC and needs to settle it in the future
inCreditVault.marketsRealizedDebtUsd += usdDelta.toInt256().toInt128();
// withdraws the USDC from the in-debt vault
inDebtVault.depositedUsdc -= usdDelta;
// decrease the in-debt vault's share of the markets realized debt
// as it has transferred USDC to the in-credit vault
inDebtVault.marketsRealizedDebtUsd -= usdDelta.toInt256().toInt128();
// emit an event
emit LogRebalanceVaultsAssets(vaultsIds[0], vaultsIds[1], usdDelta);
}

This wrong implementation will ause broken vault's states.
As time goes by, vault's imbalance will increase and protocol will be broken.

Recommended mitigation steps

  1. Modify CreditDelegationBranch.sol#settleVaultsDebt() function as follows.

function settleVaultsDebt(uint256[] calldata vaultsIds) external onlyRegisteredSystemKeepers {
// first, we need to update the credit capacity of the vaults
Vault.recalculateVaultsCreditCapacity(vaultsIds);
// working data, cache usdc address
SettleVaultDebtContext memory ctx;
ctx.usdc = MarketMakingEngineConfiguration.load().usdc;
// load the usdc collateral data storage pointer
Collateral.Data storage usdcCollateralConfig = Collateral.load(ctx.usdc);
for (uint256 i; i < vaultsIds.length; i++) {
// load the vault storage pointer
Vault.Data storage vault = Vault.loadExisting(vaultsIds[i].toUint128());
// cache the vault's unsettled debt, if zero skip to next vault
// amount in zaros internal precision
ctx.vaultUnsettledRealizedDebtUsdX18 = vault.getUnsettledRealizedDebt();
if (ctx.vaultUnsettledRealizedDebtUsdX18.isZero()) continue;
// otherwise vault has debt to be settled, cache the vault's collateral asset
ctx.vaultAsset = vault.collateral.asset;
// loads the dex swap strategy data storage pointer
DexSwapStrategy.Data storage dexSwapStrategy =
DexSwapStrategy.loadExisting(vault.swapStrategy.assetDexSwapStrategyId);
// if the vault is in debt, swap its assets to USDC
- if (ctx.vaultUnsettledRealizedDebtUsdX18.lt(SD59x18_ZERO)) {
+ if (ctx.vaultUnsettledRealizedDebtUsdX18.gt(SD59x18_ZERO)) {
// get swap amount; both input and output in native precision
ctx.swapAmount = calculateSwapAmount(
dexSwapStrategy.dexAdapter,
ctx.usdc,
ctx.vaultAsset,
usdcCollateralConfig.convertSd59x18ToTokenAmount(ctx.vaultUnsettledRealizedDebtUsdX18.abs())
);
.........................................................
} else {
.........................................................
}
// emit an event per vault settled
emit LogSettleVaultDebt(
vaultsIds[i].toUint128(),
ctx.assetIn,
ctx.assetInAmount,
ctx.assetOut,
ctx.assetOutAmount,
ctx.settledDebt
);
}
}
  1. Modify CreditDelegationBranch.sol#rebalanceVaultsAssets() function as follows.

function rebalanceVaultsAssets(uint128[2] calldata vaultsIds) external onlyRegisteredSystemKeepers {
// load the storage pointers of the vaults in net credit and net debt
Vault.Data storage inCreditVault = Vault.loadExisting(vaultsIds[0]);
Vault.Data storage inDebtVault = Vault.loadExisting(vaultsIds[1]);
// both vaults must belong to the same engine in order to have their debt
// state rebalanced, as each usd token's debt is isolated
if (inCreditVault.engine != inDebtVault.engine) {
revert Errors.VaultsConnectedToDifferentEngines();
}
// create an in-memory dynamic array in order to call `Vault::recalculateVaultsCreditCapacity`
uint256[] memory vaultsIdsForRecalculation = new uint256[](2);
vaultsIdsForRecalculation[0] = vaultsIds[0];
vaultsIdsForRecalculation[1] = vaultsIds[1];
// recalculate the credit capacity of both vaults
Vault.recalculateVaultsCreditCapacity(vaultsIdsForRecalculation);
// cache the in debt vault & in credit vault unsettled debt
SD59x18 inDebtVaultUnsettledRealizedDebtUsdX18 = inDebtVault.getUnsettledRealizedDebt();
SD59x18 inCreditVaultUnsettledRealizedDebtUsdX18 = inCreditVault.getUnsettledRealizedDebt();
// revert if 1) the vault that is supposed to be in credit is not OR
// 2) the vault that is supposed to be in debt is not
if (
- inCreditVaultUnsettledRealizedDebtUsdX18.lte(SD59x18_ZERO)
- || inDebtVaultUnsettledRealizedDebtUsdX18.gte(SD59x18_ZERO)
+ inCreditVaultUnsettledRealizedDebtUsdX18.gte(SD59x18_ZERO)
+ || inDebtVaultUnsettledRealizedDebtUsdX18.lte(SD59x18_ZERO)
) {
revert Errors.InvalidVaultDebtSettlementRequest();
}
- // get debt absolute value
- SD59x18 inDebtVaultUnsettledRealizedDebtUsdX18Abs = inDebtVaultUnsettledRealizedDebtUsdX18.abs();
+ // get credit absolute value
+ SD59x18 inCreditVaultUnsettledRealizedDebtUsdX18Abs = inCreditVaultUnsettledRealizedDebtUsdX18.abs();
- // if debt absolute value > credit, use credit value, else use debt value
- SD59x18 depositAmountUsdX18 = inCreditVaultUnsettledRealizedDebtUsdX18.gt(
- inDebtVaultUnsettledRealizedDebtUsdX18Abs
- ) ? inDebtVaultUnsettledRealizedDebtUsdX18Abs : inCreditVaultUnsettledRealizedDebtUsdX18;
+ // if debt absolute value > credit, use credit value, else use debt value
+ SD59x18 depositAmountUsdX18 = inDebtVaultUnsettledRealizedDebtUsdX18.gt(
+ inCreditVaultUnsettledRealizedDebtUsdX18Abs
+ ) ? inCreditVaultUnsettledRealizedDebtUsdX18Abs : inDebtVaultUnsettledRealizedDebtUsdX18;
..........................................................
}
Updates

Lead Judging Commences

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

settleVaultDebt functions in opposite direction because of `ctx.vaultUnsettledRealizedDebtUsdX18.lt(SD59x18_ZERO)` having an inversed comparator (should have been gt)

Improper if conditions inside `rebalanceVaultsAssets` cause reverts when it shouldn't

Support

FAQs

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