Field notes
Synthetic finding: a protocol fee that rounds to zero
A fictional example in our finding format. Small orders pay no fee, and splitting a large order is cheaper than placing it.
Synthetic example. Fictional contract. This finding is invented to show how we write one. The contract, code and numbers below are fictional. It is not from a real engagement and describes no real client, protocol or vulnerability.
NM-SYN-02 · Protocol fee rounds down to zero on small swaps
- SeverityLow (illustrative)
- ImpactLow: fee revenue lost on small orders; no user funds at risk
- LikelihoodHigh: any caller can split an order; no preconditions
- Location
ExamplePool.sol,_takeFee()(fictional) - StatusFixed (fictional)
Description
The pool charges a protocol fee in basis points. The fee is computed with integer division, which in Solidity truncates toward zero. For any amountIn below 10_000 / FEE_BPS, the fee is zero.
uint256 public constant FEE_BPS = 30; // 0.30%
function _takeFee(uint256 amountIn) internal returns (uint256 net) {
uint256 fee = amountIn * FEE_BPS / 10_000; // rounds down
protocolFees += fee;
net = amountIn - fee;
}
With FEE_BPS = 30, any input of 333 base units or fewer pays no fee. For tokens with few decimals, 333 base units can be a meaningful amount. A token with 2 decimals would let a 3.33-unit order through for free. More generally, a caller who splits one order into many small ones pays less in total, because each split truncates its own remainder.
Severity reasoning
Impact: the loss falls on fee revenue, not on depositors, and is bounded by gas cost per split. That keeps impact Low. Likelihood: anyone can do it with no special role or state, so likelihood is High. On a chain where gas is cheap relative to the token unit, splitting becomes profitable, and the rating should be revisited for that deployment.
Proof of concept (Foundry, fictional)
function test_smallSwapsPayNoFee() public {
uint256 before = pool.protocolFees();
for (uint256 i; i < 1_000; ++i) pool.swap(333, alice);
assertEq(pool.protocolFees(), before); // 333_000 units swapped, zero fee
}
Recommendation
Round the fee against the caller, so the protocol never collects less than the stated rate. One option is a ceiling division:
uint256 fee = (amountIn * FEE_BPS + 9_999) / 10_000; // rounds up
Optionally enforce a minimum input so that dust orders revert. Add an invariant to the fuzz suite: for every swap, fee * 10_000 >= amountIn * FEE_BPS. If the codebase already uses a vetted math library with explicit rounding modes, prefer that over hand-written arithmetic.
Fix review
Verified at the fix commit (fictional): the fee now rounds up, the new invariant holds under stateful fuzzing, and the PoC fails as expected.
Why we write it this way
Every finding separates impact from likelihood and says why each was chosen, so a reader can disagree with a specific step rather than with a single word. The PoC is there so the issue can be reproduced, not described. The direction of rounding is a design decision. The same principle appears in EIP-4626, which asks vault implementers to round in the vault’s favour rather than the user’s.