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)
{
amount = IDexAdapter(dexAdapter).getExpectedOutput(assetIn, assetOut, vaultUnsettledDebtUsdAbs);
}
This calls BaseAdapter::getExpectedOutput:
function getExpectedOutput(
address tokenIn,
address tokenOut,
uint256 amountIn
)
public
view
returns (uint256 expectedAmountOut)
{
if (amountIn == 0) revert Errors.ZeroExpectedSwapOutput();
UD60x18 priceTokenInX18 = IPriceAdapter(swapAssetConfigData[tokenIn].priceAdapter).getPrice();
UD60x18 priceTokenOutX18 = IPriceAdapter(swapAssetConfigData[tokenOut].priceAdapter).getPrice();
UD60x18 amountInX18 = Math.convertTokenAmountToUd60x18(swapAssetConfigData[tokenIn].decimals, amountIn);
expectedAmountOut = Math.convertUd60x18ToTokenAmount(
swapAssetConfigData[tokenOut].decimals, amountInX18.mul(priceTokenInX18).div(priceTokenOutX18)
);
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.
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();
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 = fuzzVaultConfig.depositCap/2;
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, 1e8);
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));
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
*/
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.