mintSpecial never binds the token id to the match id, and has no inverse, so one wrong call destroys two matches' claims
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.
Description
mintSpecial validates the id's range, the match's claim status and the id's mint status, but
never that the two correspond:
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:
- Match 4711's canonical Special id is
2_000_004_711. The issuance service resolves a stale record and callsmintSpecial(wrongAddr, 2_000_009_999, 4711). - All three checks pass: the id is in the Special range, match 4711 is unclaimed, and id
2_000_009_999is unminted. Both flags are written and the badge is minted. - The error is noticed.
mintSpecial(winnerA, 2_000_004_711, 4711)revertsSpecialAlreadyClaimed(4711). - Issuing
2_000_009_999for match 9999, the match it belongs to, revertsSpecialIdAlreadyMinted. - The badge cannot be moved out of
wrongAddror destroyed.
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.
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:
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.
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.
Affected files
src/PlakxioBadge.sol#L220-L230at commit5c38893