Early rate$2,400 of senior audit time for $500. Early members keep the rate as it climbs.$2,400 of senior audit time for $500See how →
F-2026-0009·precision-loss

Percentage fee truncation underpays the treasury

Fixednfterc-1155marketplace
TL;DR

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.

Severity
LOW
Impact
LOW
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
MEDIUM
Exploitability
LOW
02Section · Description

Description

calculateFee() truncates the percentage fee before comparing it with flatMinFee:

solidity
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.

03Section · Impact

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.

04Section · Recommendation

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:

TestFile
test_fee_boundary_unitstest/ArithmeticTierAndCapInvariants.t.sol
testFuzz_settlement_conservationExacttest/ArithmeticTierAndCapInvariants.t.sol
testFuzz_calculateFee_isMaxOfBranchestest/PlakxioSettlement.t.sol
05Section · Resolution

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.

06Section · Affected files

Affected files

  • src/PlakxioSettlement.sol#L569-L572 at commit 5c38893
Status
Fixed
F-2026-0009