Below the cancellation threshold, a flatMinFee raise silently reprices resting asks instead of failing them
Fee parameters are read from live storage at settlement and bounded only against the buyer's payment. Raising the flat minimum fee below the cancellation threshold silently reprices resting asks, so a seller can receive a fraction of the proceeds they signed for with nothing reverting.
Description
The fee parameters sitting outside the order signature is already a documented exposure, and
the documented consequence is a loud one: raising flatMinFee above a resting order's
execution price makes the pair fail simulation, and both makers' orders are cancelled. This
issue is about the band below that threshold, where the pair does not fail — it settles, and
the seller absorbs the difference with nothing reverting.
_finalize computes the fee from live storage and checks it only against the buyer's payment:
uint256 fee = calculateFee(executionPrice);// STRICTLY less than, not `>`. The old bound permitted fee == price, ...if (fee >= executionPrice) revert FeeExceedsPrice(fee, executionPrice);
That guard bounds the fee against executionPrice, never against the seller's proceeds, so
it guarantees the seller one base unit and nothing more. Anywhere flatMinFee < executionPrice
the settlement completes normally.
The second half is that FEE_BPS_CEILING does not bound the realised fee. calculateFee
takes the maximum of two branches and only the percentage branch is subject to it:
return Math.max((executionPrice * feeBps) / BPS_DENOMINATOR, flatMinFee);
flatMinFee is bounded by flatMinCeiling, deployed at 10_000_000 (10.00 USDC). Below an
execution price of about 66.67 USDC the flat branch wins, so for the whole cent-value book —
which DeployBase.s.sol describes as "the trades that are expected to be the most common
kind" — the advertised 15% ceiling is not the ceiling that applies.
The scenario below uses flatMinFee = 500_000. That is not an extreme value: it is the
default this parameter carried before the current 10_000, per the comment at
script/DeployBase.s.sol ("0.01, not the 0.50 this defaulted to"). Reverting it is an
ordinary product decision.
Vulnerable Scenario:
- Deployed parameters:
feeBps = 500,flatMinFee = 10_000,flatMinCeiling = 10_000_000. - A seller signs an ask at
600_000(0.60 USDC). At signing,calculateFeereturnsmax(30_000, 10_000) = 30_000, so they model proceeds of570_000. They go offline. - The admin calls
setFeeParams(500, 500_000)— inside the ceiling. - The relayer settles the unchanged, still-valid ask at
600_000. The fee is nowmax(30_000, 500_000) = 500_000, and500_000 >= 600_000is false, so the guard passes. - The seller receives
100_000(0.10 USDC), the treasury500_000, and the badge is delivered.
Impact
A resting ask maker receives 0.10 USDC where their signature was priced against 0.57 — 82% of their proceeds, transferred to the treasury — while the badge is delivered as normal. Nothing reverts, so the change does not surface as a failed simulation and the maker, who is offline by design, has no signal. Every resting ask priced between the old and new flat minimum is affected at once.
Documented invariant INV-5 holds literally throughout: the seller is paid something. The amount it guarantees is one base unit.
The scenario above is not the worst the ceiling permits. flatMinCeiling is 10.00 USDC, so
for a resting ask priced one base unit above it the fee becomes the whole price bar one:
FeeExceedsPrice requires only fee < executionPrice, so the guaranteed floor on a seller's
proceeds is exactly one quote base unit. A 10.000001 USDC ask signed against proceeds of
9.500001 settles for 0.000001 — 99.99999% redirected to the treasury, with the badge
delivered and nothing reverting. The second test below asserts that case.
Recommendation
Two changes. They are not alternatives, and the order matters: the second is what closes the harm, and the first alone does not.
Bind the maker's floor into the signature. The maker's proceeds become a property of what they signed, so a fee change invalidates the orders it would re-price instead of repricing them:
struct Order {address maker;Side side;uint256 tokenId;uint256 price;+ uint256 minProceeds;address quote;uint256 nonce;uint256 expiry;uint256 salt;}
with ORDER_TYPEHASH and hashOrder extended to match, the JSON artifact at
eip712/PlakxioOrder.json updated in step, and in _finalize:
uint256 fee = calculateFee(executionPrice);+if (executionPrice - fee < ask.minProceeds) revert ProceedsBelowSigned(executionPrice - fee, ask.minProceeds);
Clamp the realised fee to the immutable ceiling. This is worth doing alongside, because it bounds the exposure of orders signed before the field exists and needs no type-hash change:
-if (fee >= executionPrice) revert FeeExceedsPrice(fee, executionPrice);+if (fee > (executionPrice * FEE_BPS_CEILING) / BPS_DENOMINATOR) revert FeeExceedsPrice(fee, executionPrice);
It is a bound, not a closure. Sweeping flatMinFee across its whole range with the clamp
applied still reprices the ask in this issue, silently, anywhere the new flat minimum sits
between the fee at signing and the ceiling — at flatMinFee = 30_789 the seller receives
569_211 against the 570_000 they signed against, and nothing reverts:
[FAIL: settled below the proceeds the maker signed against: 569211 < 570000;counterexample: args=[30789]] testFuzz_M02_fixKeepsSellerWhole(uint256)
Applying the clamp on its own therefore turns the loud band louder and leaves the silent band silent. Only the signed floor removes the dependence on a global.
Note the clamp also leaves calculateFee itself returning a figure above the ceiling, so any
off-chain consumer quoting it should read the clamped value rather than the raw one.
Resolution
Both halves landed, and the realised-fee ceiling refuses rather than clamps, which is stronger
than the recommendation: it removes the silent band rather than bounding it. minProceeds is now
a signed field of the order, so a fee change invalidates the orders it would otherwise re-price.
This carries a client-side obligation. The protection is only as good as the value signed: a
maker whose client leaves minProceeds at zero still settles below what they modelled — now
bounded by the realised-fee ceiling to roughly 8% rather than the whole proceeds, but not removed.
Sweeping the parameter across its full range confirms the finding is closed outright when a floor
is signed, and not when it is left at zero. The order-building client should populate
minProceeds on every ask and the relayer should refuse an ask that carries zero.
Affected files
src/PlakxioSettlement.sol#L528-L533andsrc/PlakxioSettlement.sol#L569-L572at commit5c38893