The StabilityBranch contract's fee collection mechanism completely fails for low decimal tokens (e.g., USDC with 6 decimals) due to precision loss when converting from high-precision calculations (UD60x18) to actual token amounts. This results in 100% fee loss for small trades, effectively allowing fee-free trading and depriving the protocol and LPs of revenue.
The issue occurs when the contract calculates and collects fees. The critical vulnerability stems from two key components:
pragma solidity 0.8.25;
import { Base_Test } from "test/Base.t.sol";
import { StabilityBranch } from "@zaros/market-making/branches/StabilityBranch.sol";
import { UsdTokenSwapConfig } from "@zaros/market-making/leaves/UsdTokenSwapConfig.sol";
import { Collateral } from "@zaros/market-making/leaves/Collateral.sol";
import { IPriceAdapter } from "@zaros/utils/PriceAdapter.sol";
import { IERC4626 } from "@openzeppelin/interfaces/IERC4626.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/ERC20.sol";
import { ud60x18, UD60x18 } from "@prb-math/UD60x18.sol";
import { console } from "forge-std/console.sol";
import { Vm } from "forge-std/Vm.sol";
contract StabilityBranchPrecisionLoss_Test is Base_Test {
using Collateral for Collateral.Data;
uint256 public cumulativeTheoreticalFees;
uint256 public cumulativeActualFees;
function setUp() public virtual override {
Base_Test.setUp();
vm.stopPrank();
vm.startPrank(users.owner.account);
createVaults(marketMakingEngine, INITIAL_VAULT_ID, FINAL_VAULT_ID, true, address(perpsEngine));
marketMakingEngine.configureEngine(address(marketMakingEngine), address(usdToken), true);
marketMakingEngine.configureUsdTokenSwapConfig(
1e6,
30,
type(uint96).max
);
VaultConfig memory vaultConfig = getFuzzVaultConfig(INITIAL_VAULT_ID);
bytes memory priceData = getMockedSignedReport(
vaultConfig.streamId,
1e18
);
deal({
token: address(vaultConfig.asset),
to: vaultConfig.indexToken,
give: 1000000e18
});
vm.stopPrank();
}
function testPrecisionLossInFeeExtraction() external {
VaultConfig memory lowDecimalVaultConfig = getFuzzVaultConfig(INITIAL_VAULT_ID);
uint256 tradeSize = 1e6;
uint256 numTrades = 10;
uint256 initialLiquidity = 1000000 * 1e18;
deal({
token: address(lowDecimalVaultConfig.asset),
to: lowDecimalVaultConfig.indexToken,
give: initialLiquidity
});
address usdTokenSwapKeeper = usdTokenSwapKeepers[lowDecimalVaultConfig.asset];
for (uint256 i = 0; i < numTrades; i++) {
vm.startPrank(users.naruto.account);
deal({ token: address(usdToken), to: users.naruto.account, give: tradeSize });
UD60x18 priceX18 = IPriceAdapter(lowDecimalVaultConfig.priceAdapter).getPrice();
bytes memory priceData = getMockedSignedReport(
lowDecimalVaultConfig.streamId,
priceX18.intoUint256()
);
UD60x18 amountOutX18 = marketMakingEngine.getAmountOfAssetOut(
lowDecimalVaultConfig.vaultId,
ud60x18(tradeSize),
priceX18
);
(UD60x18 baseFeeX18, UD60x18 swapFeeX18) = marketMakingEngine.getFeesForAssetsAmountOut(
amountOutX18,
priceX18
);
uint256 theoreticalFee = baseFeeX18.add(swapFeeX18).intoUint256();
cumulativeTheoreticalFees += theoreticalFee;
initiateUsdSwap(lowDecimalVaultConfig.vaultId, uint128(tradeSize), 0);
vm.stopPrank();
vm.startPrank(usdTokenSwapKeeper);
marketMakingEngine.fulfillSwap(
users.naruto.account,
uint128(i + 1),
priceData,
address(marketMakingEngine)
);
vm.stopPrank();
uint256 actualFee = IERC20(lowDecimalVaultConfig.asset).balanceOf(address(marketMakingEngine));
cumulativeActualFees += actualFee;
console.log("Trade", i + 1);
console.log("Theoretical Fee", theoreticalFee);
console.log("Actual Fee", actualFee);
console.log("Running Difference", cumulativeTheoreticalFees - cumulativeActualFees);
}
assertLt(
cumulativeActualFees,
cumulativeTheoreticalFees,
"Actual fees should be less than theoretical fees due to precision loss"
);
uint256 feeDifference = cumulativeTheoreticalFees - cumulativeActualFees;
console.log("Total Theoretical Fees", cumulativeTheoreticalFees);
console.log("Total Actual Fees", cumulativeActualFees);
console.log("Total Fee Difference", feeDifference);
}
}