The Special floor is stored without the split that redeems it, so a commission raise strips a forced seller's promised profit
The ratcheted Special floor is stored without the profit and commission split that produced it, and the split is re-read live at the next sale. A commission increase retroactively reprices every existing holder and strips most of the profit the mechanic committed to them.
Description
buySpecial writes the ratcheted floor as a bare integer and records nothing about the
escalation parameters that produced it:
uint256 cBps = commissionBps;uint256 escalated = BPS_DENOMINATOR + profitBps + cBps;uint256 commission = (payment * cBps) / escalated;uint256 newFloor = (payment * escalated) / BPS_DENOMINATOR;// ── Effects ──_floorOf[tokenId] = newFloor;
newFloor is a one-way compression of three inputs into one number. At the next sale the
contract needs the split again and re-reads profitBps/commissionBps live, so a floor
written under one split is decomposed under whatever the parameters are then. The comment
directly above states the property this is meant to deliver:
the embedded commission is payment × c / (1 + p + c), so a sale at exactly the floor pays the prior owner exactly what they paid × (1 + p).
Nothing in storage enforces it. _setEscalationParams validates only the immutable ceilings
and imposes no monotonicity in either direction, unlike _setInitialFloor, which was
deliberately made raise-only. The parameter that gates entry is hardened; the two that decide
who gets paid move freely.
The holder cannot avoid the redemption. buySpecial takes no seller signature, the badge
cannot move wallet-to-wallet, and no burn exists — so a holder is redeemed under terms they
never agreed to, on a sale they cannot decline.
No malice is required. A plain commission increase from 5% to its 15% ceiling produces the result below, and the floor is unchanged by it, so nothing observable signals the change.
Vulnerable Scenario:
- Parameters are the deployed
profitBps = 1000,commissionBps = 500, soescalated = 11500. - A buyer takes a Special at
payment = 100.00 USDC._floorOfbecomes115.00. The mechanic's promise to them at that floor is100.00 × 1.10 = 110.00. - The admin calls
setEscalationParams(1000, 1500)— commission to its immutable ceiling.currentFloorstill returns115.00. - Anyone calls
buySpecial(id, holder, 115_000_000)— a sale at exactly the floor. commission = 115e6 × 1500 / 12500 = 13_800_000. The holder receives101_200_000.
Impact
A forced Special seller receives 101.20 USDC against the 110.00 the mechanic committed to — 88% of their promised profit, routed to the treasury instead. Every Special holder who bought under the previous split is re-priced simultaneously and retroactively by one call, and none of them can decline the sale that realises it.
Documented invariant INV-3 stays green throughout: the floor never decreases, it stops describing the terms the holder bought under.
Recommendation
Persist the split alongside the floor and decompose the next sale with the stored value. Live parameters continue to price the next escalation, which is correct — they should never re-price a commitment already made.
mapping(uint256 tokenId => uint256) private _floorOf;+mapping(uint256 tokenId => uint256) private _escalatedOf;+mapping(uint256 tokenId => uint256) private _commissionBpsOf;
uint256 cBps = commissionBps;uint256 escalated = BPS_DENOMINATOR + profitBps + cBps;-uint256 commission = (payment * cBps) / escalated;+uint256 escAtSale = _escalatedOf[tokenId] == 0 ? escalated : _escalatedOf[tokenId];+uint256 cAtSale = _escalatedOf[tokenId] == 0 ? cBps : _commissionBpsOf[tokenId];+uint256 commission = (payment * cAtSale) / escAtSale;uint256 newFloor = (payment * escalated) / BPS_DENOMINATOR;// ── Effects ──_floorOf[tokenId] = newFloor;+_escalatedOf[tokenId] = escalated;+_commissionBpsOf[tokenId] = cBps;
The == 0 branch covers the never-traded case, where there is no prior split to honour and
the live parameters are the right ones to use.
Resolution
The escalation terms are now stored per token, so a holder is redeemed on the terms they bought
under rather than the terms in force at the time of the later sale. Verified at audit-v2.
A badge that has never traded has no stored terms, so its first sale still uses the live split. The client accepts that case on the grounds that the claimer received the badge at no cost and the profit promise binds for buyers who paid.
Affected files
src/PlakxioSpecialMarket.sol#L142-L149at commit5c38893