Summary
while swapping the usdToken of vault with assets the user will calls getAmountOfAssetOut
to find the minAmount user will receive after swap it does not deduct Fee from amountOut but in case of actual swap we first deduct swapFee and protocol Fee and than Check the minAmountOut for slippage , this inconsistency will in most of cases result in revert.
Vulnerability Details
Let have a look at getAmountOfAssetOut
which user will calls before initiateSwap
:
/src/market-making/branches/StabilityBranch.sol:100
100: function getAmountOfAssetOut(
101: uint128 vaultId,
102: UD60x18 usdAmountInX18,
103: UD60x18 indexPriceX18
104: )
105: public
106: view
107: returns (UD60x18 amountOutX18)
108: {
109:
110: Vault.Data storage vault = Vault.load(vaultId);
111:
112:
113:
114: UD60x18 vaultAssetsUsdX18 = ud60x18(IERC4626(vault.indexToken).totalAssets()).mul(indexPriceX18);
115: if (vaultAssetsUsdX18.isZero()) revert Errors.InsufficientVaultBalance(vaultId, 0, 0);
116:
117:
118: SD59x18 vaultDebtUsdX18 = vault.getTotalDebt();
119:
120:
121:
122:
123: UD60x18 premiumDiscountFactorX18 =
124: UsdTokenSwapConfig.load().getPremiumDiscountFactor(vaultAssetsUsdX18, vaultDebtUsdX18);
125:
126:
127: amountOutX18 = usdAmountInX18.div(indexPriceX18).mul(premiumDiscountFactorX18);
128: }
129:
From the code we can see that it does not check for any fee , it return the amount of assets which user will receive after swap . but in case of swap we check this slippage after deducting the Fee:
/src/market-making/branches/StabilityBranch.sol:330
330: function fulfillSwap(
331: address user,
332: uint128 requestId,
333: bytes calldata priceData,
334: address engine
335: )
336: external
337: onlyRegisteredSystemKeepers
338: {
339:
340: UsdTokenSwapConfig.SwapRequest storage request = UsdTokenSwapConfig.load().swapRequests[user][requestId];
341:
342:
343: if (request.processed) {
344: revert Errors.RequestAlreadyProcessed(user, requestId);
345: }
346:
347:
348: FulfillSwapContext memory ctx;
349:
350:
351: ctx.deadline = request.deadline;
352: if (ctx.deadline < block.timestamp) {
353: revert Errors.SwapRequestExpired(user, requestId, ctx.deadline);
354: }
355:
356:
357: request.processed = true;
358:
359:
360: MarketMakingEngineConfiguration.Data storage marketMakingEngineConfiguration =
361: MarketMakingEngineConfiguration.load();
362:
363:
364: ctx.vaultId = request.vaultId;
365: Vault.Data storage vault = Vault.loadLive(ctx.vaultId);
366:
367:
368: ctx.usdToken = UsdToken(marketMakingEngineConfiguration.usdTokenOfEngine[engine]);
369:
370:
371: StabilityConfiguration.Data storage stabilityConfiguration = StabilityConfiguration.load();
372:
373:
374: ctx.priceX18 = stabilityConfiguration.verifyOffchainPrice(priceData);
375:
376:
377: ctx.amountIn = request.amountIn;
378: ctx.amountOutBeforeFeesX18 = getAmountOfAssetOut(ctx.vaultId, ud60x18(ctx.amountIn), ctx.priceX18);
379:
380:
381: (ctx.baseFeeX18, ctx.swapFeeX18) = getFeesForAssetsAmountOut(ctx.amountOutBeforeFeesX18, ctx.priceX18);
382:
383:
384: ctx.asset = vault.collateral.asset;
385:
386:
387: Collateral.Data storage collateral = Collateral.load(ctx.asset);
388:
389: ctx.amountOut =
390: collateral.convertUd60x18ToTokenAmount(ctx.amountOutBeforeFeesX18.sub(ctx.baseFeeX18.add(ctx.swapFeeX18)));
391:
392:
393:
394: ctx.minAmountOut = request.minAmountOut;
395: if (ctx.amountOut < ctx.minAmountOut) {
396: revert Errors.SlippageCheckFailed(ctx.minAmountOut, ctx.amountOut);
397: }
398:
399:
...
437: }
438:
Here At Line 390
we can see that we subtract the fee amount from amountOutBeforeFeesX18
and store the result in amountOut
and at line 395
than we check for slippage check , so in most of cases it will revert.
Note it is exactly same issue as the vault one mention in known issue.
POC
function testFuzz_WhenSlippageCheckPassesAndThePremiumOrDiscountIsNotZero(
uint256 vaultId,
uint256 marketId,
uint256 vaultAssetsBalance,
uint256 swapAmount,
uint256 vaultDebtAbsUsd,
bool useCredit
)
external
whenCallerIsKeeper
whenRequestWasNotYetProcessed
whenSwapRequestNotExpired
{
TestFuzz_WhenSlippageCheckPassesAndThePremiumOrDiscountIsNotZero_Context memory ctx;
ctx.fuzzVaultConfig = getFuzzVaultConfig(vaultId);
ctx.oneAsset = 10 ** ctx.fuzzVaultConfig.decimals;
changePrank({ msgSender: users.owner.account });
marketMakingEngine.configureUsdTokenSwapConfig(1, 30, type(uint96).max);
changePrank({ msgSender: users.naruto.account });
vaultAssetsBalance = bound({ x: vaultAssetsBalance, min: ctx.oneAsset, max: ctx.fuzzVaultConfig.depositCap });
vaultDebtAbsUsd = bound({ x: vaultDebtAbsUsd, min: ctx.oneAsset / 2, max: vaultAssetsBalance });
deal({
token: address(ctx.fuzzVaultConfig.asset),
to: ctx.fuzzVaultConfig.indexToken,
give: vaultAssetsBalance
});
swapAmount = vaultAssetsBalance / 1e9;
deal({ token: address(usdToken), to: users.naruto.account, give: swapAmount });
ctx.fuzzPerpMarketCreditConfig = getFuzzPerpMarketCreditConfig(marketId);
ctx.engine = IMockEngine(perpMarketsCreditConfig[ctx.fuzzPerpMarketCreditConfig.marketId].engine);
ctx.engine.setUnrealizedDebt(useCredit ? -int256(vaultDebtAbsUsd) : int256(vaultDebtAbsUsd));
ctx.minAmountOut = 0;
UD60x18 priceUsdX18 = IPriceAdapter(vaultsConfig[ctx.fuzzVaultConfig.vaultId].priceAdapter).getPrice();
ctx.priceData = getMockedSignedReport(ctx.fuzzVaultConfig.streamId, priceUsdX18.intoUint256());
ctx.usdTokenSwapKeeper = usdTokenSwapKeepers[ctx.fuzzVaultConfig.asset];
ctx.amountOut =
marketMakingEngine.getAmountOfAssetOut(ctx.fuzzVaultConfig.vaultId, ud60x18(swapAmount), priceUsdX18);
vm.assume(ctx.amountOut.intoUint256() > 0);
ctx.minAmountOut = ctx.amountOut.intoUint128();
initiateUsdSwap(uint128(ctx.fuzzVaultConfig.vaultId), swapAmount, ctx.minAmountOut);
(ctx.baseFeeX18, ctx.swapFeeX18) = marketMakingEngine.getFeesForAssetsAmountOut(ctx.amountOut, priceUsdX18);
ctx.amountOutAfterFee = convertUd60x18ToTokenAmount(
ctx.fuzzVaultConfig.asset, ctx.amountOut.sub(ctx.baseFeeX18.add(ctx.swapFeeX18))
);
changePrank({ msgSender: ctx.usdTokenSwapKeeper });
ctx.protocolSwapFee = ctx.swapFeeX18.mul(ud60x18(marketMakingEngine.exposed_getTotalFeeRecipientsShares()));
ctx.protocolReward =
convertUd60x18ToTokenAmount(ctx.fuzzVaultConfig.asset, ctx.baseFeeX18.add(ctx.protocolSwapFee));
ctx.requestId = 1;
marketMakingEngine.fulfillSwap(
users.naruto.account, ctx.requestId, ctx.priceData, address(marketMakingEngine)
);
}
Add this test case to FulFillSwap.t.sol
file and run with command :forge test --mt testFuzz_WhenSlippageCheckPassesAndThePremiumOrDiscountIsNotZero -vvv
Impact
Due to Fee Deduction and than checking for slippage will always result in revert.
Tools Used
Manual Review
Recommendations
Either add it to known issue list or check for slippage before deducting fee.