Our synthetic collection permits nine lifetime mints and already contains eight. A request for one token must succeed and a second request must fail. A comparison that rejects only counts greater than nine allows the second request to cross the limit before the guard notices.
The independent example represents Solidity mint accounting using plain JavaScript state. Its small cap is chosen for inspection and has no relationship to an actual collection. We model reservation of supply before an external receiver runs so the intermediate state is part of the test.
Write the invariant in terms of the requested transition
The precondition is that the requested quantity is positive and fits into the remaining capacity. Testing the old total alone is insufficient for a batch. With eight already issued and a cap of nine the old total is valid but a request for two tokens is not.
We compare quantity against cap minus issued. This states the remaining capacity directly and avoids adding two unchecked integers in a Solidity implementation. Solidity 0.8 checks ordinary arithmetic overflow but the subtraction still requires the existing invariant that issued never exceeds the cap.
function mint(state, quantity, receiver = () => {}) {
if (quantity <= 0n) throw new Error("quantity");
if (quantity > state.cap - state.issued) throw new Error("capacity");
state.issued += quantity;
receiver();
}The fixture reserves the accepted quantity before invoking the receiver callback. That callback can request another mint and sees the updated lifetime count. We make it try to mint after an exact final fill and assert that the second operation is rejected.
The cap has to name what it counts
A lifetime issuance cap differs from a cap on current supply. Burning a token reduces current supply but does not necessarily restore mint capacity. The contract needs to decide that policy explicitly because an ERC721 owner interface does not define a collection's issuance economics.
A per wallet limit is another independent counter. A request can fit within global capacity while exceeding the wallet's remaining allowance. If a whitelist period grants a special allowance then the transition must satisfy both that period's rule and the global invariant.
We keep the lifetime counter separate from ownership state so a burn cannot accidentally reopen issuance unless that behavior is an explicit collection rule and the test suite verifies the resulting state transitions across minting and burning as well as across transfers between accounts that have different remaining allowances.
The fixture intentionally omits whitelist proofs and payment handling. Adding those mechanisms before the supply invariant works increases the number of explanations available when a test fails. We first establish the cap and then combine it with eligibility checks in the contract suite.
A safe receiver is still external code
ERC721 safe minting can call a recipient contract. That boundary deserves the same care as a token payment callback because the recipient can execute code before the outer operation returns. Reserving quantity early means the second invocation sees the capacity already consumed.
A full contract also needs to keep token identifiers and ownership state consistent with its callback order. Updating a counter alone does not prove the entire mint path is safe. Cross function access during the callback may expose other state that the first invocation has not completed.
Exact fill reserves the final token before the callback
8 assertions passedOur test callback catches the expected rejection and lets the outer mint complete. This is deliberate because the JavaScript model does not roll back mutations on an uncaught exception. An EVM integration test must separately verify rollback when a recipient rejects the transfer and the outer transaction reverts.
Test equality and batch boundaries independently
We exercise a request that exactly fills capacity and another request that exceeds it by one. We also reject a zero quantity and verify that a failed batch leaves the counter unchanged. These cases pin down the meaning of every comparison without requiring a large collection fixture.
For Hardhat or Foundry we repeat the test through every mint entry point. Administrative minting and promotional distribution often reach the same supply counter through different authorization paths. A cap checked in one public function does not constrain a second path automatically.
One final token and a receiver invoking a second authorized mint path make a compact integration regression. The supply invariant must survive that complete call sequence even when the recursive request reaches a different public function.