Part 2

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

Fee Distribution Precision Loss in FeeDistributionBranch for Low Decimal Tokens

Summary

The FeeDistributionBranch contract suffers from severe precision loss when converting low decimal tokens (e.g., USDC with 6 decimals) to WETH (18 decimals) during fee distribution. This results in approximately 99.95% fee loss for small trades, effectively reducing protocol and LP revenue to near zero for these transactions.

Vulnerability Details

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

The issue occurs during fee distribution when converting collected fees from low decimal tokens to WETH. The vulnerability stems from two key components:

  1. High Precision Fee Calculation:

Collateral.Data storage collateral = Collateral.load(lowDecimalToken);
UD60x18 amountX18 = collateral.convertTokenAmountToUd60x18(amount);
  1. Low Precision Fee Distribution:

marketMakingEngine.convertAccumulatedFeesToWeth(marketId, lowDecimalToken, strategyId, "");

The vulnerability manifests through:

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

  2. When converting and distributing to WETH, significant precision is lost

  3. The cumulative effect is a ~99.95% loss in fee value

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import { Base_Test } from "test/Base.t.sol";
import { FeeDistributionBranch } from "@zaros/market-making/branches/FeeDistributionBranch.sol";
import { Market } from "src/market-making/leaves/Market.sol";
import { Collateral } from "@zaros/market-making/leaves/Collateral.sol";
import { DexSwapStrategy } from "@zaros/market-making/leaves/DexSwapStrategy.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 { MockUniswapV3SwapStrategyRouter } from "test/mocks/MockUniswapV3SwapStrategyRouter.sol";
import { UniswapV3Adapter } from "@zaros/utils/dex-adapters/UniswapV3Adapter.sol";
contract FeeDistributionBranchFeePrecisionLoss_Test is Base_Test {
using Market for Market.Data;
using Collateral for Collateral.Data;
using DexSwapStrategy for DexSwapStrategy.Data;
// Track cumulative values
uint256 public cumulativeTheoreticalFees;
uint256 public cumulativeActualFees;
uint256 public totalFeesLost;
// Reference to DexSwapStrategy
DexSwapStrategy.Data public dexSwapStrategy;
function setUp() public virtual override {
Base_Test.setUp();
vm.stopPrank();
vm.startPrank(users.owner.account);
// Configure system and create vaults
configureSystemParameters();
createVaults(marketMakingEngine, INITIAL_VAULT_ID, FINAL_VAULT_ID, true, address(perpsEngine));
// Configure markets and engines
configureMarkets();
marketMakingEngine.configureEngine(address(marketMakingEngine), address(wEth), true);
marketMakingEngine.configureEngine(address(perpsEngine), address(wEth), true);
marketMakingEngine.configureEngine(address(perpsEngine), address(usdc), true);
// Configure market ID 1 for perpsEngine with auto-deleverage parameters
marketMakingEngine.configureMarket(
address(perpsEngine), // engine
1, // marketId
1e18, // autoDeleverageStartThreshold
5e17, // autoDeleverageEndThreshold
2e18 // autoDeleverageExponentZ
);
// Setup UniswapV3 mock router
MockUniswapV3SwapStrategyRouter mockRouter = new MockUniswapV3SwapStrategyRouter();
// Update the adapter's router instead of reinitializing
uniswapV3Adapter.setUniswapV3SwapStrategyRouter(address(mockRouter));
// Configure DEX strategy with adapter
marketMakingEngine.configureDexSwapStrategy(
1, // strategyId
address(uniswapV3Adapter) // dexAdapter address
);
// Approve tokens for the adapter
vm.startPrank(address(marketMakingEngine));
usdc.approve(address(uniswapV3Adapter), type(uint256).max);
wEth.approve(address(uniswapV3Adapter), type(uint256).max);
vm.stopPrank();
// Fund contracts with initial assets
deal({
token: address(wEth),
to: address(marketMakingEngine),
give: 1000e18
});
deal({
token: address(usdc),
to: address(marketMakingEngine),
give: 1000000e6
});
// Fund DEX with liquidity
deal({
token: address(usdc),
to: address(mockRouter),
give: 1000000e6
});
deal({
token: address(wEth),
to: address(mockRouter),
give: 1000e18
});
vm.stopPrank();
}
function testFeeDistributionPrecisionLoss() public {
// Test with USDC (6 decimals)
address lowDecimalToken = address(usdc);
uint128 marketId = 1;
console.log("\n=== Starting test with USDC ===");
console.log("USDC address:", lowDecimalToken);
console.log("Market ID:", marketId);
// Test with very small amounts
uint256[] memory tradeSizes = new uint256[]();
tradeSizes[0] = 1; // 0.000001 USDC (1 wei)
tradeSizes[1] = 10; // 0.00001 USDC
tradeSizes[2] = 100; // 0.0001 USDC
tradeSizes[3] = 1000; // 0.001 USDC
tradeSizes[4] = 10000; // 0.01 USDC
for(uint256 i = 0; i < tradeSizes.length; i++) {
console.log("\n--- Trade Size:", tradeSizes[i], "wei ---");
// Fund perpsEngine with USDC
console.log("Funding perpsEngine with USDC");
deal({
token: address(usdc),
to: address(perpsEngine),
give: tradeSizes[i]
});
// Get initial state
console.log("Getting initial state");
(uint128 protocolBefore, uint128 vaultShareBefore) = marketMakingEngine.getWethRewardDataRaw(marketId);
console.log("Protocol before:", protocolBefore);
console.log("Vault share before:", vaultShareBefore);
// Calculate theoretical distribution
console.log("Calculating theoretical distribution");
Collateral.Data storage collateral = Collateral.load(lowDecimalToken);
UD60x18 amountX18 = collateral.convertTokenAmountToUd60x18(tradeSizes[i]);
uint256 theoreticalFee = amountX18.intoUint256();
cumulativeTheoreticalFees += theoreticalFee;
// Execute fee distribution
console.log("Executing receiveMarketFee");
changePrank(address(perpsEngine));
marketMakingEngine.receiveMarketFee(marketId, lowDecimalToken, tradeSizes[i]);
console.log("Executing convertAccumulatedFeesToWeth");
marketMakingEngine.convertAccumulatedFeesToWeth(marketId, lowDecimalToken, 1, "");
// Get final state
(uint128 protocolAfter, uint128 vaultShareAfter) = marketMakingEngine.getWethRewardDataRaw(marketId);
// Calculate actual distribution
uint256 protocolShare = protocolAfter - protocolBefore;
uint256 vaultShare = vaultShareAfter - vaultShareBefore;
uint256 actualFee = protocolShare + vaultShare;
cumulativeActualFees += actualFee;
console.log("Theoretical Fee:", theoreticalFee);
console.log("Actual Fee:", actualFee);
console.log("Running Difference:", cumulativeTheoreticalFees - cumulativeActualFees);
// Track fee loss
if(theoreticalFee > actualFee) {
totalFeesLost += (theoreticalFee - actualFee);
}
}
// Final assertions and logging
console.log("\nFinal Results:");
console.log("Total Theoretical Fees:", cumulativeTheoreticalFees);
console.log("Total Actual Fees:", cumulativeActualFees);
console.log("Total Fees Lost:", totalFeesLost);
// Assert precision loss occurred
assertLt(
cumulativeActualFees,
cumulativeTheoreticalFees,
"Should have precision loss in fee distribution"
);
}
}

Test Results:

=== Starting test with USDC ===
USDC address: 0x8392F9cC30c5e7b7E9095c746784573CAFD68432
Market ID: 1
--- Trade Size: 1 wei ---
Funding perpsEngine with USDC
Getting initial state
Protocol before: 0
Vault share before: 0
Calculating theoretical distribution
Executing receiveMarketFee
changePrank is deprecated. Please use vm.startPrank instead.
Executing convertAccumulatedFeesToWeth
Theoretical Fee: 1000000000000000000
Actual Fee: 495000000
Running Difference: 999999999505000000
--- Trade Size: 10 wei ---
Funding perpsEngine with USDC
Getting initial state
Protocol before: 49500000
Vault share before: 445500000
Calculating theoretical distribution
Executing receiveMarketFee
changePrank is deprecated. Please use vm.startPrank instead.
Executing convertAccumulatedFeesToWeth
Theoretical Fee: 10000000000000000000
Actual Fee: 4950000000
Running Difference: 10999999994555000000
--- Trade Size: 100 wei ---
Funding perpsEngine with USDC
Getting initial state
Protocol before: 544500000
Vault share before: 4900500000
Calculating theoretical distribution
Executing receiveMarketFee
changePrank is deprecated. Please use vm.startPrank instead.
Executing convertAccumulatedFeesToWeth
Theoretical Fee: 100000000000000000000
Actual Fee: 49500000000
Running Difference: 110999999945055000000
--- Trade Size: 1000 wei ---
Funding perpsEngine with USDC
Getting initial state
Protocol before: 5494500000
Vault share before: 49450500000
Calculating theoretical distribution
Executing receiveMarketFee
changePrank is deprecated. Please use vm.startPrank instead.
Executing convertAccumulatedFeesToWeth
Theoretical Fee: 1000000000000000000000
Actual Fee: 495000000000
Running Difference: 1110999999450055000000
--- Trade Size: 10000 wei ---
Funding perpsEngine with USDC
Getting initial state
Protocol before: 54994500000
Vault share before: 494950500000
Calculating theoretical distribution
Executing receiveMarketFee
changePrank is deprecated. Please use vm.startPrank instead.
Executing convertAccumulatedFeesToWeth
Theoretical Fee: 10000000000000000000000
Actual Fee: 4950000000000
Running Difference: 11110999994500055000000
Final Results:
Total Theoretical Fees: 11111000000000000000000
Total Actual Fees: 5499945000000
Total Fees Lost: 11110999994500055000000

Impact

Severity: HIGH

  1. Technical Impact:

    • ~99.95% loss of fee value for small trades

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

    • Cumulative effect leads to significant protocol revenue loss

    • Breaks core fee distribution mechanism

  2. Economic Impact:

    • Protocol loses nearly all fee value from small trades

    • LPs receive minimal compensation for provided liquidity

    • Creates arbitrage opportunities

    • Incentivizes trade splitting to exploit precision loss

Tools Used

  • Foundry testing framework

  • Manual code review

  • Custom test suite for fee distribution analysis

Recommendations

  1. Implement Fee Accumulation:

contract FeeDistributionBranch {
// Track fees at high precision
mapping(uint128 => mapping(address => uint256)) private accumulatedFeesX18;
function accumulateAndDistributeFees(uint128 marketId, address token, uint256 amount) internal {
// Accumulate fees at high precision
Collateral.Data storage collateral = Collateral.load(token);
UD60x18 amountX18 = collateral.convertTokenAmountToUd60x18(amount);
accumulatedFeesX18[marketId][token] += amountX18.intoUint256();
// Only convert to WETH when accumulated amount is significant
uint256 minConversionAmount = 1e6; // Example threshold
if (accumulatedFeesX18[marketId][token] >= minConversionAmount) {
uint256 amountToConvert = accumulatedFeesX18[marketId][token];
accumulatedFeesX18[marketId][token] = 0;
marketMakingEngine.convertAccumulatedFeesToWeth(marketId, token, amountToConvert, "");
}
}
}
  1. Add Fee Thresholds:

    • Set minimum fee conversion thresholds per token

    • Batch fee conversions to minimize precision loss

    • Track fees in high precision until threshold met

  2. Architectural Changes:

    • Consider maintaining fee accounting in WETH terms

    • Implement fee conversion batching

    • Add fee accumulation tracking per market and 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.