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-0011·missing-validation

Ordinary badge ids have no supply bound, so the one-per-id semantic the id scheme implies is not contract-enforced

Fixednfterc-1155marketplace
TL;DR

Ordinary badge ids can be minted in any quantity any number of times, so the one-unit-per-id semantic the id scheme implies is not enforced on-chain.

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

Description

mint validates the id's class and never the amount:

solidity
function mint(address to, uint256 id, uint256 amount, bytes calldata data) external onlyRole(MINTER_ROLE) {
_checkMintableId(id);
_mint(to, id, amount, data);
}

_checkMintableId rejects only Special ids, so any ordinary id can be minted in any quantity, any number of times. There is no per-id supply registry and no totalSupply, so nothing on chain records or bounds how many units of a given id exist.

mintSpecial shows the opposite decision was taken where it was judged to matter: it hard-codes amount 1 and enforces one-of-one through specialClaimed[matchId] and specialIdMinted[id]. The id scheme itself implies the same one-per-id semantic for ordinary tiers — ids encode a tier and an ordinal, and the documented capacity model is one badge per match per tier.

The consequence is that the scarcity of an ordinary badge is a property of issuance-service behaviour rather than of the contract, and an off-chain indexer cannot detect a divergence from contract state because none is recorded. Settlement is unaffected: SellerMissingBadge checks < 1 and each order fills once, so N units correctly support N independent asks.

Reported as the missing invariant rather than as an attack: reaching it requires MINTER_ROLE, and an issuer over-issuing is the issuer acting against its own users rather than an external party acting against them.

03Section · Impact

Impact

The scarcity of an ordinary badge is a property of issuance-service behaviour rather than of the contract, and nothing on chain records or bounds how many units of a given id exist. An off-chain indexer cannot detect a divergence, because there is no contract state to diverge from. Settlement is unaffected: each order fills once, so N units correctly support N independent asks.

04Section · Recommendation

Recommendation

Mirror the Special path for ordinary ids:

diff
+mapping(uint256 tokenId => bool) public ordinaryIdMinted;
+error OrdinaryIdAlreadyMinted(uint256 id);
function _checkMintableId(uint256 id) internal pure {
if (tokenClass(id) == TokenClass.Special) revert SpecialTokenIdForbidden(id);
}

with the flag set in mint and each mintBatch element, and the check raising OrdinaryIdAlreadyMinted. Note _checkMintableId is pure and would need to become view.

If a tier legitimately needs multiple units, store a per-id maxSupply at first mint and check subsequent mints against it instead. Either way the invariant becomes contract-enforced and an indexer can verify it.

Separately, mint(to, id, 0, "") currently succeeds and emits TransferSingle with a zero value, which an indexer treating any transfer event as an ownership grant would record as a badge that does not exist. Rejecting a zero amount closes that.

05Section · Resolution

Resolution

A supply cap is established on first mint and re-asserted afterwards, with a per-id minted total and every mint path routed through one recorder, so the one-per-id semantic the id scheme implies is now enforced by the contract. Verified at audit-v2.

06Section · Affected files

Affected files

  • src/PlakxioBadge.sol#L201-L204 and src/PlakxioBadge.sol#L220-L230 at commit 5c38893
Status
Fixed
F-2026-0011