Ordinary badge ids have no supply bound, so the one-per-id semantic the id scheme implies is not contract-enforced
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.
Description
mint validates the id's class and never the amount:
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.
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.
Recommendation
Mirror the Special path for ordinary ids:
+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.
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.
Affected files
src/PlakxioBadge.sol#L201-L204andsrc/PlakxioBadge.sol#L220-L230at commit5c38893