Percentage fee truncation underpays the treasury
The percentage fee is truncated before it is compared with the flat minimum, so the treasury is underpaid whenever the product is not a multiple of the denominator.
Description
calculateFee() truncates the percentage fee before comparing it with flatMinFee:
return Math.max((executionPrice * feeBps) / BPS_DENOMINATOR, flatMinFee);
Integer division discards the remainder, so the fee actually charged is below the
configured percentage whenever executionPrice * feeBps is not a multiple of
BPS_DENOMINATOR. With the deployed feeBps = 500, an execution price of
1.000001 USDC has an exact 5% fee of 50,000.05 base units; the contract
charges 50,000, while the smallest representable fee not below 5% is 50,001.
At 5%, 19 of every 20 base-unit price residues carry a non-zero remainder, and the
discarded fraction reaches 0.95 base units. The treasury therefore receives up to
one base unit (0.000001 USDC) less than the configured rate on almost every
settlement, and _executePayment pays that difference to the seller instead —
quote.safeTransfer(seller, executionPrice - fee) is computed from the same
truncated fee.
Impact
The treasury is under-paid relative to the configured rate on almost every settlement, and the
difference is paid to the seller instead. The shortfall is at most one quote base unit
(0.000001 USDC) per settlement, so the loss is an accounting leak rather than a material one —
but it is systematic and always in the same direction.
Recommendation
Round the percentage fee toward its recipient rather than truncating: take the
ceiling of executionPrice * feeBps / BPS_DENOMINATOR before the Math.max
against flatMinFee.
Keep the multiplication checked. Applying Math.ceilDiv to the checked product
preserves the existing overflow revert. Replacing the whole expression with a
512-bit helper such as Math.mulDiv(..., Math.Rounding.Ceil) computes the same
fee but removes that revert, retiring the guard test_calculateFee_overflowReverts
exists to prove.
Three existing tests encode the truncating formula and need updating with the same rounding direction:
| Test | File |
|---|---|
test_fee_boundary_units | test/ArithmeticTierAndCapInvariants.t.sol |
testFuzz_settlement_conservationExact | test/ArithmeticTierAndCapInvariants.t.sol |
testFuzz_calculateFee_isMaxOfBranches | test/PlakxioSettlement.t.sol |
Resolution
calculateFee now rounds toward the treasury using Math.ceilDiv over the checked product,
so the overflow revert on executionPrice * feeBps is preserved — Math.mulDiv would have
removed it. Verified at audit-v2.
Affected files
src/PlakxioSettlement.sol#L569-L572at commit5c38893