QuantAMM

QuantAMM
49,600 OP
View results
Submission Details
Severity: low
Invalid

QuantAMMWeightedPool Requires Minimum Swap Amount of 1001 wei

Summary

The QuantAMMWeightedPool implementation enforces an undocumented minimum swap amount of 1001 wei, despite the mathematical model and whitepaper suggesting that any non-zero amount should be valid. This creates unexpected behavior for integrators and could affect protocols attempting to execute minimal test trades or precise arbitrage.

Vulnerability Details

Location: pkg/pool-quantamm/contracts/QuantAMMWeightedPool.sol

The issue manifests when attempting to execute swaps with amounts less than 1001 wei. Despite the mathematical model supporting any non-zero amount, the implementation returns 0 for output amounts when the input is below this threshold.

Proof of Concept:

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol";
import { PoolRoleAccounts, SwapKind, PoolSwapParams } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol";
import { CastingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/CastingHelpers.sol";
import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/test/ArrayHelpers.sol";
import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol";
import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol";
import { QuantAMMWeightedPool } from "../../../contracts/QuantAMMWeightedPool.sol";
import { QuantAMMWeightedPoolFactory } from "../../../contracts/QuantAMMWeightedPoolFactory.sol";
import { QuantAMMWeightedPoolContractsDeployer } from "../utils/QuantAMMWeightedPoolContractsDeployer.sol";
import { OracleWrapper } from "@balancer-labs/v3-interfaces/contracts/pool-quantamm/OracleWrapper.sol";
import { MockUpdateWeightRunner } from "../../../contracts/mock/MockUpdateWeightRunner.sol";
import { MockMomentumRule } from "../../../contracts/mock/mockRules/MockMomentumRule.sol";
import { MockChainlinkOracle } from "../../../contracts/mock/MockChainlinkOracles.sol";
import { IUpdateRule } from "@balancer-labs/v3-interfaces/contracts/pool-quantamm/IUpdateRule.sol";
import { IQuantAMMWeightedPool } from "@balancer-labs/v3-interfaces/contracts/pool-quantamm/IQuantAMMWeightedPool.sol";
contract QuantammWeightedPoolTest is QuantAMMWeightedPoolContractsDeployer, BaseVaultTest {
using CastingHelpers for address[];
using ArrayHelpers for *;
using FixedPoint for uint256;
// Maximum swap fee of 10%
uint64 public constant MAX_SWAP_FEE_PERCENTAGE = 10e16;
QuantAMMWeightedPoolFactory internal quantAMMWeightedPoolFactory;
function setUp() public override {
int216 fixedValue = 1000;
uint delay = 3600;
super.setUp();
(address ownerLocal, address addr1Local, address addr2Local) = (vm.addr(1), vm.addr(2), vm.addr(3));
owner = ownerLocal;
addr1 = addr1Local;
addr2 = addr2Local;
// Deploy UpdateWeightRunner contract
vm.startPrank(owner);
updateWeightRunner = new MockUpdateWeightRunner(owner, addr2, false);
chainlinkOracle = _deployOracle(fixedValue, delay);
updateWeightRunner.addOracle(OracleWrapper(chainlinkOracle));
vm.stopPrank();
quantAMMWeightedPoolFactory = deployQuantAMMWeightedPoolFactory(
IVault(address(vault)),
365 days,
"Factory v1",
"Pool v1"
);
vm.label(address(quantAMMWeightedPoolFactory), "quantamm weighted pool factory");
}
function _createPoolParams() internal returns (QuantAMMWeightedPoolFactory.NewPoolParams memory retParams) {
PoolRoleAccounts memory roleAccounts;
IERC20[] memory tokens = [
address(dai),
address(usdc),
address(weth),
address(wsteth),
address(veBAL),
address(waDAI),
address(usdt),
address(waUSDC)
].toMemoryArray().asIERC20();
MockMomentumRule momentumRule = new MockMomentumRule(owner);
int256[] memory initialWeights = new int256[]();
uint256[] memory initialWeightsUint = new uint256[]();
for(uint i = 0; i < 8; i++) {
initialWeights[i] = 0.125e18; // Equal weights of 12.5%
initialWeightsUint[i] = 0.125e18; // Equal weights of 12.5%
}
uint64[] memory lambdas = new uint64[]();
lambdas[0] = 0.2e18;
int256[][] memory parameters = new int256[][]();
parameters[0] = new int256[]();
parameters[0][0] = 0.2e18;
address[][] memory oracles = new address[][]();
oracles[0] = new address[]();
oracles[0][0] = address(chainlinkOracle);
retParams = QuantAMMWeightedPoolFactory.NewPoolParams(
"Pool With Donation",
"PwD",
vault.buildTokenConfig(tokens),
initialWeightsUint,
roleAccounts,
MAX_SWAP_FEE_PERCENTAGE,
address(0),
true,
false,
ZERO_BYTES32,
initialWeights,
IQuantAMMWeightedPool.PoolSettings(
new IERC20[](8),
IUpdateRule(momentumRule),
oracles,
60,
lambdas,
0.01e18,
0.01e18,
0.01e18,
parameters,
address(0)
),
initialWeights,
initialWeights,
3600,
0,
new string[][]()
);
}
function _getDefaultBalances() internal pure returns (uint256[] memory balances) {
balances = new uint256[](8);
balances[0] = 1000e18;
balances[1] = 2000e18;
balances[2] = 500e18;
balances[3] = 350e18;
balances[4] = 750e18;
balances[5] = 7500e18;
balances[6] = 8000e18;
balances[7] = 5000e18;
}
function testFindMinimumViaBinarySearch() public {
QuantAMMWeightedPoolFactory.NewPoolParams memory params = _createPoolParams();
(address pool, ) = quantAMMWeightedPoolFactory.create(params);
uint256[] memory balances = _getDefaultBalances();
uint256 low = 1;
uint256 high = 1e18; // Start with 1 token
uint256 lastWorking = 0;
// Binary search to find minimum working amount
for(uint i = 0; i < 60; i++) { // 60 iterations is more than enough for convergence
uint256 mid = (low + high) / 2;
PoolSwapParams memory swapParams = PoolSwapParams({
kind: SwapKind.EXACT_IN,
amountGivenScaled18: mid,
balancesScaled18: balances,
indexIn: 0,
indexOut: 7,
router: address(router),
userData: abi.encode(0)
});
vm.prank(address(vault));
uint256 amountOut = QuantAMMWeightedPool(pool).onSwap(swapParams);
if(amountOut > 0) {
high = mid;
lastWorking = mid;
} else {
low = mid + 1;
}
}
console.log("Minimum working amount:", lastWorking);
}
}

Test Results:

Minimum working amount: 1001

Impact

Severity: MEDIUM

  1. Technical Impact:

    • Swaps below 1001 wei return 0 output

    • Undocumented behavior contradicts mathematical model

    • Affects all weighted pool swaps

    • Could break integrator assumptions

  2. Economic Impact:

    • Prevents execution of very small trades

    • May affect precise arbitrage strategies

    • Could impact protocols using test trades

    • Unexpected behavior for integrators

Tools Used

  • Foundry testing framework

  • Binary search implementation

  • Manual code review

  • Whitepaper analysis

Recommendations

  1. Remove Minimum Amount Requirement:

function onSwap(PoolSwapParams memory params)
public
override
returns (uint256)
{
// Remove or reduce minimum amount check
// Any non-zero amount should be valid
require(params.amountGivenScaled18 > 0, "Amount must be positive");
// Continue with swap calculation
...
}
  1. If Minimum Required:

    • Document the minimum amount requirement

    • Provide clear reasoning for the 1001 wei threshold

    • Add explicit checks with clear error messages

    • Consider making the minimum configurable

  2. Testing Improvements:

    • Add explicit tests for minimum amounts

    • Document edge cases in test suite

    • Add integration tests with small amounts

    • Test across different token decimals

References

Updates

Lead Judging Commences

n0kto Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Informational or Gas / Admin is trusted / Pool creation is trusted / User mistake / Suppositions

Please read the CodeHawks documentation to know which submissions are valid. If you disagree, provide a coded PoC and explain the real likelyhood and the detailed impact on the mainnet without any supposition (if, it could, etc) to prove your point.

Support

FAQs

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