Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
M-02·audit-finding

At feeBps == FEE_BPS_CEILING, opposite rounding makes 19 of every 20 prices unsettleable

Fixednfterc-1155marketplace
TL;DR

At feeBps == FEE_BPS_CEILING, opposite rounding makes 19 of every 20 prices unsettleable. Final disposition: Fixed.

Severity
MEDIUM
Method
MManual review
CAT.

Tracker: issue #18 · Status: Fixed · Verified at: audit-v3-rereview-handoff

02Section · Affected files

Affected files

src/PlakxioSettlement.sol#L860-L886 src/PlakxioSettlement.sol#L950-L973 src/PlakxioSettlement.sol#L1017-L1025

03Section · Description

Description

setFeeParams permits feeBps == FEE_BPS_CEILING. At that setting, calculateFee rounds the percentage fee upward while _finalize rounds the realised ceiling downward. When the product has a remainder, the fee is one quote-token base unit above its ceiling and settlement reverts.

With the 1,500-bps ceiling, a price is accepted only when price * 1500 is divisible by 10,000, which reduces to price % 20 == 0. Consequently, only one of every twenty integer prices can settle. quoteFee repeats the refusal. With flatMinFee = 10,000, minSettleablePrice returns 66,667 even though the first settleable price at this fee setting is 66,680.

Vulnerable Scenario:

  1. An administrator sets feeBps to the permitted maximum of 1,500.
  2. Makers sign otherwise valid orders at a price that is not a multiple of 20 base units.
  3. A relayer submits the match.
  4. The upward-rounded fee exceeds the downward-rounded ceiling, so settlement reverts before payment or badge transfer.

The live Base Sepolia setting was 500 bps at block 46,678,271, so the condition was latent at that snapshot.

04Section · Impact

Impact

At a legal fee configuration, 19 of every 20 integer execution prices become untradeable, leaving valid signed orders unfillable until the administrator lowers the fee.

05Section · PoC

PoC

Save the following as test/poc/FeeCeilingUnsettleable.t.sol and run:

code
forge test --match-path 'test/poc/FeeCeilingUnsettleable.t.sol' -vv
solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {AuthorizationFixture} from "../helpers/AuthorizationFixture.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {PlakxioBadge} from "../../src/PlakxioBadge.sol";
import {PlakxioSettlement} from "../../src/PlakxioSettlement.sol";
import {PlakxioOrderTypes} from "../../src/PlakxioOrderTypes.sol";
import {MockUSDC} from "../mocks/MockUSDC.sol";
contract FeeCeilingUnsettleable is AuthorizationFixture {
PlakxioBadge internal badge;
PlakxioSettlement internal settlement;
MockUSDC internal usdc;
address internal admin = makeAddr("admin");
address internal minter = makeAddr("minter");
address internal relayer = makeAddr("relayer");
address internal treasury = makeAddr("treasury");
uint256 internal sellerKey = 0xA11CE;
uint256 internal buyerKey = 0xB0B;
address internal seller;
address internal buyer;
uint256 internal constant TOKEN_ID = 42;
uint256 internal constant TOKEN_ID_2 = 43;
uint256 internal constant FLAT_MIN_CEILING = 10e6;
uint256 internal constant INITIAL_FEE_BPS = 500;
uint256 internal constant INITIAL_FLAT_MIN = 1e6;
function setUp() public {
seller = vm.addr(sellerKey);
buyer = vm.addr(buyerKey);
badge = new PlakxioBadge(admin, "uri", ADMIN_DELAY, SETTLEMENT_DELAY);
usdc = new MockUSDC();
settlement = new PlakxioSettlement(
admin,
badge,
IERC20(address(usdc)),
treasury,
FLAT_MIN_CEILING,
INITIAL_FEE_BPS,
INITIAL_FLAT_MIN,
ADMIN_DELAY
);
vm.startPrank(admin);
badge.grantRole(badge.MINTER_ROLE(), minter);
badge.setSettlementContract(address(settlement), true, badge.CLASS_MASK_ORDINARY());
skip(3 days);
settlement.grantRole(settlement.RELAYER_ROLE(), relayer);
vm.stopPrank();
vm.startPrank(minter);
badge.mint(seller, TOKEN_ID, 1, TEST_SUPPLY_CAP, "");
badge.mint(seller, TOKEN_ID_2, 1, TEST_SUPPLY_CAP, "");
vm.stopPrank();
usdc.mint(buyer, 10_000e6);
}
function _order(address maker, PlakxioOrderTypes.Side side, uint256 tokenId, uint256 price, uint256 salt)
internal
view
returns (PlakxioOrderTypes.Order memory)
{
return PlakxioOrderTypes.Order({
maker: maker,
side: side,
tokenId: tokenId,
price: price,
quote: address(usdc),
nonce: 0,
expiry: block.timestamp + 1 days,
salt: salt,
makerIsContract: false,
minProceeds: 0
});
}
function _sign(uint256 key, PlakxioOrderTypes.Order memory order) internal view returns (bytes memory) {
(uint8 v, bytes32 r, bytes32 s) = vm.sign(key, settlement.orderDigest(order));
return abi.encodePacked(r, s, v);
}
function _tradeHappens(uint256 tokenId, uint256 price, uint256 salt) internal returns (bool) {
PlakxioOrderTypes.Order memory ask =
_order(seller, PlakxioOrderTypes.Side.Ask, tokenId, price, salt);
PlakxioOrderTypes.Order memory bid =
_order(buyer, PlakxioOrderTypes.Side.Bid, tokenId, price, salt + 1);
bytes memory askSig = _sign(sellerKey, ask);
bytes memory bidSig = _sign(buyerKey, bid);
PlakxioSettlement.PaymentAuthorization memory auth =
_authorize(usdc, address(settlement), buyerKey, price);
bytes memory data =
abi.encodeCall(PlakxioSettlement.settle, (ask, askSig, bid, bidSig, price, auth));
vm.prank(relayer);
(bool ok,) = address(settlement).call(data);
return ok && badge.balanceOf(buyer, tokenId) == 1;
}
function test_feeBpsMayEqualTheCeiling() public {
uint256 ceilingBps = settlement.FEE_BPS_CEILING();
vm.prank(admin);
settlement.setFeeParams(ceilingBps, INITIAL_FLAT_MIN);
assertEq(settlement.feeBps(), ceilingBps);
}
function test_atTheCeiling_anOrdinaryTradeCannotSettle() public {
uint256 ceilingBps = settlement.FEE_BPS_CEILING();
vm.prank(admin);
settlement.setFeeParams(ceilingBps, INITIAL_FLAT_MIN);
uint256 sellerBefore = usdc.balanceOf(seller);
// 100.000001 USDC: an ordinary price with a non-zero division remainder.
assertFalse(_tradeHappens(TOKEN_ID, 100_000_001, 100), "trade unexpectedly happened");
assertEq(badge.balanceOf(seller, TOKEN_ID), 1, "seller lost badge");
assertEq(usdc.balanceOf(seller), sellerBefore, "seller was paid");
assertTrue(_tradeHappens(TOKEN_ID_2, 100_000_000, 200), "control trade failed");
}
function test_atTheCeiling_nineteenOfEveryTwentyPricesAreUnsettleable() public {
uint256 ceilingBps = settlement.FEE_BPS_CEILING();
vm.prank(admin);
settlement.setFeeParams(ceilingBps, INITIAL_FLAT_MIN);
uint256 settleable;
for (uint256 price = 100_000_000; price < 100_000_100; ++price) {
(, bool ok) = settlement.quoteFee(price);
if (ok) ++settleable;
}
assertEq(settleable, 5, "unexpected settleable count");
}
function test_atTheCeiling_minSettleablePriceIsItselfUnsettleable() public {
uint256 ceilingBps = settlement.FEE_BPS_CEILING();
vm.prank(admin);
settlement.setFeeParams(ceilingBps, INITIAL_FLAT_MIN);
(, bool ok) = settlement.quoteFee(settlement.minSettleablePrice());
assertFalse(ok, "minimum price unexpectedly settles");
}
}

Observed at commit d8fa452dc423d00760d3159f290d82b7cc5e1857:

code
[PASS] test_atTheCeiling_anOrdinaryTradeCannotSettle() (gas: 358871)
[PASS] test_atTheCeiling_minSettleablePriceIsItselfUnsettleable() (gas: 25176)
[PASS] test_atTheCeiling_nineteenOfEveryTwentyPricesAreUnsettleable() (gas: 194924)
[PASS] test_feeBpsMayEqualTheCeiling() (gas: 23355)
Suite result: ok. 4 passed; 0 failed; 0 skipped
06Section · Recommendations

Recommendations

Use the same rounding direction for the percentage fee and its realised ceiling in both _finalize and quoteFee:

diff
-uint256 ceiling = (executionPrice * FEE_BPS_CEILING) / BPS_DENOMINATOR;
+uint256 ceiling = Math.ceilDiv(executionPrice * FEE_BPS_CEILING, BPS_DENOMINATOR);

This permits the representable integer fee to exceed the exact mathematical 15% value by less than one quote-token base unit. If the ceiling must remain a strict mathematical bound, reject feeBps == FEE_BPS_CEILING instead and test the remaining fee and dust-price boundaries.

07Section · Remediation

Remediation

Fixed. _realisedCeiling rounds up with Math.ceilDiv, so the ceiling and the fee now round the same way, and minSettleablePrice is recomputed from the three real constraints rather than the one. The asymmetry the finding described is gone.

08Section · Verification provenance

Verification provenance

Final disposition: Fixed. Verified at audit-v3-rereview-handoff, commit f020448976eb7c4895e1626ef3228876cff2b7d2. This earlier remediation disposition is carried forward from the prior re-review, not independently re-verified in the generation-d pass. The report-wide scope and evidence limitations remain applicable; see the final report overview.

Status
Fixed
M-02