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-0003·incorrect-accounting

The Special floor is stored without the split that redeems it, so a commission raise strips a forced seller's promised profit

Fixednfterc-1155marketplace
TL;DR

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.

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

Description

buySpecial writes the ratcheted floor as a bare integer and records nothing about the escalation parameters that produced it:

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

  1. Parameters are the deployed profitBps = 1000, commissionBps = 500, so escalated = 11500.
  2. A buyer takes a Special at payment = 100.00 USDC. _floorOf becomes 115.00. The mechanic's promise to them at that floor is 100.00 × 1.10 = 110.00.
  3. The admin calls setEscalationParams(1000, 1500) — commission to its immutable ceiling. currentFloor still returns 115.00.
  4. Anyone calls buySpecial(id, holder, 115_000_000) — a sale at exactly the floor.
  5. commission = 115e6 × 1500 / 12500 = 13_800_000. The holder receives 101_200_000.
03Section · Impact

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.

04Section · Recommendation

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.

diff
mapping(uint256 tokenId => uint256) private _floorOf;
+mapping(uint256 tokenId => uint256) private _escalatedOf;
+mapping(uint256 tokenId => uint256) private _commissionBpsOf;
diff
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.

05Section · Resolution

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.

06Section · Affected files

Affected files

  • src/PlakxioSpecialMarket.sol#L142-L149 at commit 5c38893
Status
Fixed
F-2026-0003