Part 2

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

Complete Fee Loss in StabilityBranch Due to Precision Loss with Low Decimal Tokens

Summary

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.

Vulnerability Details

Location: src/market-making/branches/StabilityBranch.sol

The issue occurs when the contract calculates and collects fees. The critical vulnerability stems from two key components:

  1. High Precision Fee Calculation:

(UD60x18 baseFeeX18, UD60x18 swapFeeX18) = marketMakingEngine.getFeesForAssetsAmountOut(
amountOutX18,
priceX18
);
  1. Low Precision Fee Collection:

// Track actual fees collected
uint256 actualFee = IERC20(vaultConfig.asset).balanceOf(address(marketMakingEngine));

The vulnerability manifests through:

  1. Fees are calculated correctly in high precision (UD60x18)

  2. When converting to actual token amounts with lower decimals, all fees are rounded down to 0

  3. The cumulative effect is a complete loss of fee collection

Proof of Concept

// SPDX-License-Identifier: MIT
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;
// Track cumulative rounding errors
uint256 public cumulativeTheoreticalFees;
uint256 public cumulativeActualFees;
function setUp() public virtual override {
Base_Test.setUp();
vm.stopPrank(); // Stop any existing pranks
vm.startPrank(users.owner.account);
// Create and configure vaults
createVaults(marketMakingEngine, INITIAL_VAULT_ID, FINAL_VAULT_ID, true, address(perpsEngine));
marketMakingEngine.configureEngine(address(marketMakingEngine), address(usdToken), true);
// Configure fees to make rounding more visible
marketMakingEngine.configureUsdTokenSwapConfig(
1e6, // Small base fee
30, // 0.3% swap fee
type(uint96).max
);
// Initialize price feed with non-zero price
VaultConfig memory vaultConfig = getFuzzVaultConfig(INITIAL_VAULT_ID);
bytes memory priceData = getMockedSignedReport(
vaultConfig.streamId,
1e18 // Set initial price to $1
);
// Fund vault with initial assets
deal({
token: address(vaultConfig.asset),
to: vaultConfig.indexToken,
give: 1000000e18 // Large initial balance
});
vm.stopPrank();
}
function testPrecisionLossInFeeExtraction() external {
// Setup test with a low decimal token (e.g. USDC with 6 decimals)
VaultConfig memory lowDecimalVaultConfig = getFuzzVaultConfig(INITIAL_VAULT_ID);
// Configure small trade size to maximize rounding impact
uint256 tradeSize = 1e6; // $1 worth of tokens
uint256 numTrades = 10; // Execute multiple trades
// Fund vault with significant liquidity
uint256 initialLiquidity = 1000000 * 1e18; // Large amount to avoid liquidity issues
deal({
token: address(lowDecimalVaultConfig.asset),
to: lowDecimalVaultConfig.indexToken,
give: initialLiquidity
});
// Setup keeper
address usdTokenSwapKeeper = usdTokenSwapKeepers[lowDecimalVaultConfig.asset];
for (uint256 i = 0; i < numTrades; i++) {
// Setup trade
vm.startPrank(users.naruto.account);
deal({ token: address(usdToken), to: users.naruto.account, give: tradeSize });
// Get price data
UD60x18 priceX18 = IPriceAdapter(lowDecimalVaultConfig.priceAdapter).getPrice();
bytes memory priceData = getMockedSignedReport(
lowDecimalVaultConfig.streamId,
priceX18.intoUint256()
);
// Calculate theoretical fees (high precision)
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;
// Execute trade
initiateUsdSwap(lowDecimalVaultConfig.vaultId, uint128(tradeSize), 0);
vm.stopPrank();
vm.startPrank(usdTokenSwapKeeper);
marketMakingEngine.fulfillSwap(
users.naruto.account,
uint128(i + 1),
priceData,
address(marketMakingEngine)
);
vm.stopPrank();
// Track actual fees collected
uint256 actualFee = IERC20(lowDecimalVaultConfig.asset).balanceOf(address(marketMakingEngine));
cumulativeActualFees += actualFee;
// Log intermediate results
console.log("Trade", i + 1);
console.log("Theoretical Fee", theoreticalFee);
console.log("Actual Fee", actualFee);
console.log("Running Difference", cumulativeTheoreticalFees - cumulativeActualFees);
}
// Assert that actual fees are less than theoretical fees due to precision loss
assertLt(
cumulativeActualFees,
cumulativeTheoreticalFees,
"Actual fees should be less than theoretical fees due to precision loss"
);
// Calculate and log the final fee difference
uint256 feeDifference = cumulativeTheoreticalFees - cumulativeActualFees;
console.log("Total Theoretical Fees", cumulativeTheoreticalFees);
console.log("Total Actual Fees", cumulativeActualFees);
console.log("Total Fee Difference", feeDifference);
}
}

Test Results:

Trade 1
Theoretical Fee: 500
Actual Fee: 0
Running Difference: 500
Trade 2
Theoretical Fee: 500
Actual Fee: 0
Running Difference: 1000
...
Total Theoretical Fees: 5000
Total Actual Fees: 0
Total Fee Difference: 5000 (100% loss)

Impact

Severity: HIGH

  1. Technical Impact:

    • Complete loss of fees for small trades

    • Affects all low decimal tokens (USDC, USDT, etc.)

    • Cumulative effect leads to significant protocol revenue loss

    • Breaks core fee collection mechanism

  2. Economic Impact:

    • Protocol loses all fee revenue from small trades

    • LPs receive no compensation for provided liquidity

    • Creates arbitrage opportunities

    • Incentivizes trade splitting to avoid fees

Tools Used

  • Foundry testing framework

  • Manual code review

  • Custom test suite for fee precision analysis

Recommendations

  1. Implement Fee Accumulation:

contract StabilityBranch {
// Track fees at high precision
mapping(address => uint256) private accumulatedFeesX18;
function collectFees(address token, uint256 feeAmountX18) internal {
// Accumulate fees at high precision
accumulatedFeesX18[token] += feeAmountX18;
// Only convert to token decimals when accumulated amount is significant
uint256 collectibleAmount = accumulatedFeesX18[token] / (10 ** (18 - IERC20(token).decimals()));
if (collectibleAmount > 0) {
accumulatedFeesX18[token] -= collectibleAmount * (10 ** (18 - IERC20(token).decimals()));
IERC20(token).safeTransfer(feeRecipient, collectibleAmount);
}
}
}
  1. Add Fee Thresholds:

    • Set minimum fee collection thresholds per token

    • Batch fee collections to avoid precision loss

    • Track fees in high precision until threshold met

  2. Architectural Changes:

    • Consider using basis points for fee calculations

    • Implement fee collection batching

    • Add fee accumulation tracking per token

Updates

Lead Judging Commences

inallhonesty Lead Judge
7 months ago
inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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