Part 2

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

Incorrect swap amount in CreditDelegationBranch::settleVaultsDebt improperly inflates the tokens to swap leading to DOS or/and oversettling vault debt

Summary

A bug in the CreditDelegationBranch::settlevaultsdeposit results in an incorrect calculation of the swap amount when the vault is in debt. Instead of converting the vault’s unsettled debt from its USD representation to the vault asset’s native token amount, the function mistakenly uses the USDC collateral conversion. This leads to an inaccurate swap amount when attempting to exchange the vault’s assets for USDC, potentially resulting in an improper settlement of the vault’s debt.

Vulnerability Details

In the debt branch of the settleVaultsDebt function, when a vault is in debt (ctx.vaultUnsettledRealizedDebtUsdX18.lt(SD59x18_ZERO)), the swap amount is calculated by calling:

ctx.swapAmount = calculateSwapAmount(
dexSwapStrategy.dexAdapter,
ctx.usdc,
ctx.vaultAsset,
usdcCollateralConfig.convertSd59x18ToTokenAmount(ctx.vaultUnsettledRealizedDebtUsdX18.abs())
);

CreditDelegationBranch::calculateSwapAmount gets the expected output from a swap. See below:

function calculateSwapAmount(
address dexAdapter,
address assetIn,
address assetOut,
uint256 vaultUnsettledDebtUsdAbs
)
public
view
returns (uint256 amount)
{
// calculate expected asset amount needed to cover the debt
amount = IDexAdapter(dexAdapter).getExpectedOutput(assetIn, assetOut, vaultUnsettledDebtUsdAbs);
}

This calls BaseAdapter::getExpectedOutput:

function getExpectedOutput(
address tokenIn,
address tokenOut,
uint256 amountIn
)
public
view
returns (uint256 expectedAmountOut)
{
// fail fast for zero input
if (amountIn == 0) revert Errors.ZeroExpectedSwapOutput();
// get token prices
UD60x18 priceTokenInX18 = IPriceAdapter(swapAssetConfigData[tokenIn].priceAdapter).getPrice();
UD60x18 priceTokenOutX18 = IPriceAdapter(swapAssetConfigData[tokenOut].priceAdapter).getPrice();
// convert input amount from native to internal zaros precision
UD60x18 amountInX18 = Math.convertTokenAmountToUd60x18(swapAssetConfigData[tokenIn].decimals, amountIn);
//c this function assumes that the token decimals is always <= 18,and with all the token supported by zaros, this condition is met. it then converts the amountIn to 18 decimals
// calculate the expected amount out in native precision of output token
expectedAmountOut = Math.convertUd60x18ToTokenAmount(
swapAssetConfigData[tokenOut].decimals, amountInX18.mul(priceTokenInX18).div(priceTokenOutX18)
);
// revert when calculated expected output is zero; must revert here
// otherwise the subsequent slippage bps calculation will also
// return a minimum swap output of zero giving away the input tokens
if (expectedAmountOut == 0) revert Errors.ZeroExpectedSwapOutput();
}

which returns the expectedoutput from the swap to ctx.swapAmount in CreditDelegationBranch::settleVaultsDebt. ctx.swapAmount is then passed as the assetAmount variable into CreditDelegation::_convertAssetsToUsdc which is the function that performs the swap.

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

Proof Of Code (POC)

function test_settlevaultdebtdoesnotperformasintended( uint128 vaultId,
uint128 assetsToDeposit,
uint128 marketId,
uint128 adapterIndex
)
external
{
vm.stopPrank();
//c configure vaults and markets
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);
// ensure valid deposit amount
address userA = users.naruto.account;
assetsToDeposit = fuzzVaultConfig.depositCap/2;
deal(fuzzVaultConfig.asset, userA, 100e18);
vm.prank(userA);
marketMakingEngine.deposit(fuzzVaultConfig.vaultId, assetsToDeposit, 0, "", false);
//c fund engine with tokens to depositcreditformarket
deal(fuzzVaultConfig.asset, address(fuzzMarketConfig.engine), 100e18);
//c perp engine deposits credit into market
vm.prank(address(fuzzMarketConfig.engine));
marketMakingEngine.depositCreditForMarket(fuzzMarketConfig.marketId, fuzzVaultConfig.asset, 1e8);
//c recalculatevaultcreditcapacity is not called in depositcreditformarket which is a bug I reported so i have to call vaultrouterbranch::deposit again to update the credit capacity of the vault. could have also called CreditDelegationBranch::updateVaultCreditCapacity but I chose this way instead
address userB = users.sasuke.account;
deal(fuzzVaultConfig.asset, userB, 100e18);
vm.startPrank(userB);
marketMakingEngine.deposit(fuzzVaultConfig.vaultId, assetsToDeposit, 0, "", false);
vm.stopPrank();
IDexAdapter adapter = getFuzzDexAdapter(adapterIndex);
vm.startPrank(users.owner.account);
marketMakingEngine.updateVaultSwapStrategy(
fuzzVaultConfig.vaultId, "", "", adapter.STRATEGY_ID(), adapter.STRATEGY_ID()
);
vm.stopPrank();
uint256 marketcreditdeposit = marketMakingEngine.workaround_getMarketCreditDeposit(fuzzMarketConfig.marketId, fuzzVaultConfig.asset);
console.log(marketcreditdeposit);
vm.prank(address(perpsEngine));
//c attempt to settlevaultdebt but this will revert with ERC20InsufficientBalance as the usdc representation to the vault asset’s native token amount is not the correct value to use to perform the swap
/*c to run this test, I commented out the following lines in vault::recalculateVaultsCreditCapacity:
if (
ctx.realizedDebtChangeUsdX18.isZero() && ctx.unrealizedDebtChangeUsdX18.isZero()
&& ctx.usdcCreditChangeX18.isZero() && ctx.wethRewardChangeX18.isZero()
) {
continue;
}
If this block is not commented out, the vault debt will always return 0 due to the access guard issue which I have reported
*/
//c attempt to settlevaultdebt but this will revert with ERC20InsufficientBalance as the usdc representation of the vault asset’s native token amount is not the correct value to use to perform the swap. to view the logs, run forge test --mt test_settlevaultdebtdoesnotperformasintended -vvvv
vm.expectRevert();
marketMakingEngine.settleVaultsDebt(vaultIds);
}

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

Inaccurate Debt Settlement: The improper conversion means the swap amount for the vault’s assets is miscalculated, resulting in the vault's debt being settled incorrectly. Over time, this can cause the vault's recorded debt to diverge from its actual debt, leading to accounting errors.

Financial Discrepancies: An inaccurate swap may leave the vault with either an excess or deficit of USDC relative to its debt position. Such imbalances can negatively impact the vault’s credit capacity and overall financial integrity.

Tools Used

Manual Review, Foundry

Recommendations

Instead of using the USDC collateral conversion result directly as the token input for the swap, modify the logic so that ctx.swapAmount is used to determine the expected output amount for slippage purposes. In practice, this means:

Continue to compute ctx.swapAmount using calculateSwapAmount as the expected output amount to pass to the dex adapter as a value to prevent slippage from being too high.

When executing the swap via _convertAssetsToUsdc, pass the value obtained from:

usdcCollateralConfig.convertSd59x18ToTokenAmount(ctx.vaultUnsettledRealizedDebtUsdX18.abs())

as the input token amount, rather than using ctx.swapAmount.

Updates

Lead Judging Commences

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

CreditDelegationBranch::settleVaultsDebt uses dex output expected token amount as input incorrectly, leading to failed debt settlements

Support

FAQs

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