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-0006·input-validation

mintSpecial never binds the token id to the match id, and has no inverse, so one wrong call destroys two matches' claims

Fixednfterc-1155marketplace
TL;DR

mintSpecial never checks that the token id corresponds to the match id, and both flags it writes are permanent, so one wrong call consumes the claims of two matches with no way to reverse it.

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

Description

mintSpecial validates the id's range, the match's claim status and the id's mint status, but never that the two correspond:

solidity
function mintSpecial(address to, uint256 id, uint256 matchId) external onlyRole(MINTER_ROLE) {
if (tokenClass(id) != TokenClass.Special) revert NotSpecialTokenId(id);
if (specialClaimed[matchId]) revert SpecialAlreadyClaimed(matchId);
if (specialIdMinted[id]) revert SpecialIdAlreadyMinted(id);
specialClaimed[matchId] = true;
specialIdMinted[id] = true;
emit SpecialClaimed(matchId, id, to);
_mint(to, id, 1, "");
}

There is no derivation and no registry tying id to matchId. Both flags are then written permanently — neither has a setter or a reset anywhere in the contract, and PlakxioBadge exposes no burn (OpenZeppelin's _burn is internal and not surfaced). mintSpecial has no inverse.

The consequence is that a single wrong-id call consumes two matches' entitlements: the match named in the call can never be re-issued because its claim flag is set, and the id used can never be issued for the match it actually belongs to because its mint flag is set.

The wrongly-issued badge is also unrecoverable. It cannot be burned, and the walled garden admits no mover other than a whitelisted operator, so the rightful winner's only route to it is to buy it through the Special market at the current floor — paying an unrelated address that keeps the proceeds.

This is the on-chain/off-chain token-id mismatch class that the id ceiling exists to surface loudly. It surfaces, and is then permanent.

Vulnerable Scenario:

  1. Match 4711's canonical Special id is 2_000_004_711. The issuance service resolves a stale record and calls mintSpecial(wrongAddr, 2_000_009_999, 4711).
  2. All three checks pass: the id is in the Special range, match 4711 is unclaimed, and id 2_000_009_999 is unminted. Both flags are written and the badge is minted.
  3. The error is noticed. mintSpecial(winnerA, 2_000_004_711, 4711) reverts SpecialAlreadyClaimed(4711).
  4. Issuing 2_000_009_999 for match 9999, the match it belongs to, reverts SpecialIdAlreadyMinted.
  5. The badge cannot be moved out of wrongAddr or destroyed.
03Section · Impact

Impact

Two matches lose their one-of-one Special entitlement permanently from one call, and the badge that was minted sits with an address that did not earn it, unrecoverable by the platform. The rightful winner's only path to their own prize is to purchase it at the market floor from that address. The on-chain claim registry and the off-chain record are left permanently inconsistent for both matches, with no reconciliation available.

04Section · Recommendation

Recommendation

Make the mismatch unrepresentable rather than recoverable. Deriving the id from the match removes the failure mode entirely and makes specialIdMinted redundant with specialClaimed:

diff
function mintSpecial(address to, uint256 id, uint256 matchId) external onlyRole(MINTER_ROLE) {
if (tokenClass(id) != TokenClass.Special) revert NotSpecialTokenId(id);
+ if (id != SPECIAL_ID_START + matchId) revert SpecialIdMatchMismatch(id, matchId);
if (specialClaimed[matchId]) revert SpecialAlreadyClaimed(matchId);

This requires matchId < SPECIAL_ID_END - SPECIAL_ID_START (1e9 matches), which is well above the ~38k/year the id scheme is sized for. Keep specialIdMinted as a redundant assertion if you prefer belt-and-braces; it costs one SLOAD and would now be unreachable.

If the id must remain independent of the match, then the registry needs an inverse — a MINTER_ROLE function that burns the badge and clears both flags, gated on the token still sitting with its original mint recipient so it can never reach a badge someone has paid for. That is strictly more surface than the derivation above, which is why the derivation is the recommendation.

05Section · Resolution

Resolution

mintSpecial now requires the token id to derive from the ordinal, so the id and the entitlement cannot diverge and a single wrong call can no longer consume two matches' claims. Verified at audit-v2.

06Section · Affected files

Affected files

  • src/PlakxioBadge.sol#L220-L230 at commit 5c38893
Status
Fixed
F-2026-0006