Part 2

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

Incorrect Handling of Redeem Fee Discount in VaultRouterBranch::getIndexTokenSwapRate which leads to a redeem fees being double counted for users who require

Summary

The bug occurs in the way the redeem fee is applied during the calculation of expected asset output for share redemption. In the VaultRouterBranch::getIndexTokenSwapRate function, when the shouldDiscountRedeemFee flag is true, the redeem fee is subtracted from the computed preview assets out. However, in the overall redeem flow, the redeem fee is later subtracted from the expected assets. This double‑discounting (or misapplication) of the fee results in an incorrect expected asset value, causing users to receive less assets than they should. The bug is compounded by the fact that if the fee should be waived (i.e. not charged), the discount logic should instead add back the fee—effectively canceling the subtraction that occurs in the redeem function—rather than subtracting it.

Vulnerability Details

Misapplied Fee Discount in VaultRouterBranch::getIndexTokenSwapRate:

The function getIndexTokenSwapRate computes a preliminary value, previewAssetsOut, based on the vault’s net credit capacity and share ratio.When shouldDiscountRedeemFee is true, the code subtracts the fee:

previewAssetsOut = ud60x18(previewAssetsOut)
.sub(ud60x18(previewAssetsOut).mul(ud60x18(vault.redeemFee)))
.intoUint256();

This logic subtracts the fee from the preview assets, which then returns a lower asset amount. However, in VaultRouterBranch::redeem, after receiving expectedAssetsX18 from VaultRouterBranch::getIndexTokenSwapRate, the redeem fee is again applied by subtracting a fraction of expectedAssetsX18:

ctx.expectedAssetsMinusRedeemFeeX18 = ctx.expectedAssetsX18.sub(
ctx.expectedAssetsX18.mul(ud60x18(ctx.redeemFee))
);
This results in the fee being effectively applied twice if shouldDiscountRedeemFee is true.

If shouldDiscountRedeemFee is true, it indicates that the expected asset output should not be reduced by the fee at this stage (or should be adjusted such that later fee subtraction in the redeem function results in the correct net value). In other words, the fee should be added back (or not discounted) in getIndexTokenSwapRate so that the final redeem calculation subtracts the fee exactly once.

The current code subtracts the fee in getIndexTokenSwapRate, and then subtracts it again in the redeem flow, leading to an excessive fee deduction. As a consequence, users redeeming their shares receive fewer assets than expected. This miscalculation undermines user trust and can result in significant financial discrepancies, particularly in high‑volume or high‑value redemptions.

Proof Of Code (POC)

function test_redeemfeesubtractedtwice(uint128 vaultId,
uint256 assetsToDeposit,
uint128 marketId
)
external
{
vm.stopPrank();
//c configure vaults and markets
VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);
vm.assume(fuzzVaultConfig.asset != address(wBtc)); //c to avoid overflow issues
vm.assume(fuzzVaultConfig.asset != address(usdc)); //c to log market debt
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);
Vault.UpdateParams memory params = Vault.UpdateParams({
vaultId: fuzzVaultConfig.vaultId,
depositCap: 10e18,
withdrawalDelay: fuzzVaultConfig.withdrawalDelay,
isLive: true,
lockedCreditRatio: 0.5e18
});
//c update lockedcreditratio of vault by updating vault configuration
vm.prank( users.owner.account );
marketMakingEngine.updateVaultConfiguration(params);
//c get updated deposit cap of vault
uint128 depositCap = marketMakingEngine.workaround_Vault_getDepositCap(fuzzVaultConfig.vaultId);
console.log(depositCap);
//c user deposits into vault
address userA = users.naruto.account;
assetsToDeposit = depositCap;
fundUserAndDepositInVault(userA, fuzzVaultConfig.vaultId, uint128(assetsToDeposit));
uint128 userVaultShares = uint128(IERC20(fuzzVaultConfig.indexToken).balanceOf(userA));
//c get predicted amount of assets out
UD60x18 assetsOut = marketMakingEngine.getIndexTokenSwapRate(fuzzVaultConfig.vaultId, userVaultShares, true);
console.log(assetsOut.unwrap());
// initiate the withdrawal
vm.startPrank(userA);
marketMakingEngine.initiateWithdrawal(fuzzVaultConfig.vaultId, userVaultShares);
// fast forward block.timestamp to after withdraw delay has passed
skip(fuzzVaultConfig.withdrawalDelay + 1);
//c get totalcreditcapacity before redeem
/*c
to run this test, I changed the following line in VaultRouterBranch::redeem to be able to set VaultRouterBranch::getIndexTokenSwapRate to true as there is currently no logic for any address to be able to be discounted from fees which is a seperate issue I reported
RedeemContext memory ctx;
ctx.shares = withdrawalRequest.shares;
ctx.expectedAssetsX18 = getIndexTokenSwapRate(vaultId, ctx.shares, true);
*/
//c user redeem all their assets successfully
marketMakingEngine.redeem(fuzzVaultConfig.vaultId, 1, 0);
vm.stopPrank();
//c if fee should be discounted then the expectedassets out should be the same as the assets that user A redeems from the vault but it will be less which is proven by the assert below
uint256 userAbal = IERC20(fuzzVaultConfig.asset).balanceOf(userA);
assert(userAbal != assetsOut.unwrap());
}

Impact

Financial Loss: Users will receive less than the expected amount of underlying assets upon redemption due to the double application of the redeem fee.
Protocol Imbalance: The overall accounting within the vault may become inconsistent if the redeem fee is applied incorrectly, potentially affecting credit capacity calculations and subsequent financial operations.

Tools Used

Manual Review, Foundry

Recommendations

Correct Fee Discount Logic in getIndexTokenSwapRate:

Modify the fee application in getIndexTokenSwapRate so that when shouldDiscountRedeemFee is true, the redeem fee is added to (or not subtracted from) previewAssetsOut. This ensures that the final redeem fee is applied only once in the redeem function.

if (shouldDiscountRedeemFee) {
// Instead of subtracting, add back the fee percentage (or do not discount at all)
previewAssetsOut = ud60x18(previewAssetsOut)
.add(ud60x18(previewAssetsOut).mul(ud60x18(vault.redeemFee)))
.intoUint256();
}

Note: The exact arithmetic may need to be adjusted to correctly cancel the later subtraction in the redeem function.

Reevaluate the overall redeem flow to ensure that the redeem fee is applied exactly once. Consider centralizing the fee adjustment logic either in getIndexTokenSwapRate or in the redeem function to avoid duplicated adjustments. Ensure that the shouldDiscountRedeemFee flag correctly reflects whether the fee should be waived or applied, and document the intended behavior clearly.

Updates

Lead Judging Commences

inallhonesty Lead Judge
5 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.