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-0002·business-logic

Every freshly claimed Special is purchasable at a global dust floor in the next block

Acknowledgednfterc-1155marketplace
TL;DR

Every Special badge that has not yet traded shares one global opening floor and nothing gates a purchase after a claim, so a freshly claimed one-of-one badge is purchasable at that floor in the next block. Accepted as a mechanism-design trade-off; the client showed the stated impact was overstated.

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

Description

currentFloor returns one global initialFloor for every Special badge that has not yet traded:

solidity
function currentFloor(uint256 tokenId) public view returns (uint256) {
uint256 floor = _floorOf[tokenId];
return floor == 0 ? initialFloor : floor;
}

mintSpecial writes no per-badge price and no claim timestamp, so the market has nothing to gate on, and buySpecial has no opening window. From the block in which SpecialClaimed is emitted, a one-of-one badge is purchasable by anyone for the deployed initialFloor of 500_000 (0.50 USDC), regardless of what that particular badge is worth.

The claimant has no defence available. buySpecial takes no seller signature — consent is established at claim time — so they cannot refuse. They cannot raise their own floor: only a completed purchase writes _floorOf, and SelfBuy blocks the direct route. They cannot move the badge out of the walled garden, and no burn exists.

The mechanic's stated rationale for starting low is that the ascending floor finds the market-clearing price:

The mechanic promises liquidity and fairness — never profit: a rising floor is not rising value, and the auction naturally ends when the badge reaches its market-clearing price.

That rationale does not extend to the claimant. The ratchet is +15% per sale at deployed parameters, so reaching any meaningful price takes tens of forced transfers — and the claimant exits at the first one, at 0.478261 USDC, while the first buyer captures the whole gap between the global floor and the badge's value.

setInitialFloor is the only lever and it is global and raise-only, so protecting one high-value badge permanently re-prices every future low-value Special.

Vulnerable Scenario:

  1. The issuance service calls mintSpecial(claimant, id, matchId). SpecialClaimed is emitted.
  2. _floorOf[id] is 0, so currentFloor(id) returns initialFloor = 500_000.
  3. A bot watching that event calls buySpecial(id, claimant, 500_000) in the next block. Class, balance, SelfBuy and BelowFloor all pass.
  4. commission = 500_000 * 500 / 11_500 = 21_739. The claimant receives 478_261; the badge moves to the bot; _floorOf becomes 575_000.
  5. Anyone wanting the badge afterwards — the claimant included — must pay the bot at least 575_000, and the bot captures every subsequent ratchet.
03Section · Impact

Impact

The claimant of a one-of-one match badge — the user the mechanic exists to reward — is bought out for 0.478261 USDC in the block after the mint, for any badge whose value exceeds that, with no action available to them at any point. The gap between the global floor and the badge's value accrues entirely to whoever polls the event fastest.

The platform also loses: its commission is 5% of a suppressed 0.50 USDC clearing price rather than of the badge's real one, and the ascending auction begins from a number unrelated to the asset.

The loss magnitude tracks the badges' market value, which is outside the audited scope. The mechanism, the absence of any claimant-side defence and the zero preconditions are established below.

04Section · Recommendation

Recommendation

Bind the starting price to the badge rather than to a global. Set it where the match is already known — the issuance path — and consult it before the global fallback:

diff
+mapping(uint256 tokenId => uint256) public initialFloorOf;
+
+function setInitialFloorFor(uint256 tokenId, uint256 floor) external onlyRole(DEFAULT_ADMIN_ROLE) {
+ if (floor < initialFloorOf[tokenId]) revert InitialFloorBelowCurrent(floor, initialFloorOf[tokenId]);
+ initialFloorOf[tokenId] = floor;
+}
+
function currentFloor(uint256 tokenId) public view returns (uint256) {
uint256 floor = _floorOf[tokenId];
- return floor == 0 ? initialFloor : floor;
+ if (floor != 0) return floor;
+ uint256 opening = initialFloorOf[tokenId];
+ return opening == 0 ? initialFloor : opening;
}

If a per-badge price is not practical at issuance, the alternative is an opening window: record specialClaimedAt[id] = block.timestamp in mintSpecial and have buySpecial refuse a token whose _floorOf is still 0 until that timestamp plus a fixed delay has passed. That gives the claimant a bounded period to act without giving them a veto, so the no-seller-signature property of the mechanic is preserved either way.

05Section · Resolution

Resolution

Accepted risk, and the impact stated in this finding was overstated. The client demonstrated, with passing tests, that a buyer purchasing at the opening floor captures the ratchet step rather than the gap to fair value: paying 500,000 leaves the badge takeable at 575,000 and returns 550,000 when it is taken — a profit of 0.05 USDC. Buying cheaply is what makes a position cheap to take back, and overpaying raises the floor and therefore buys retention.

We re-derived that arithmetic and accept the correction. What remains is that the claimant exits at the first rung and every later rung is a sale they are not part of, which the client characterises as a knowing mechanism-design trade rather than a costless property.

06Section · Affected files

Affected files

  • src/PlakxioSpecialMarket.sol#L165-L167 and src/PlakxioBadge.sol#L220-L230 at commit 5c38893
F-2026-0002