Building the Quantum Genesis ERC-721: Batch Minting, EIP-2981, and a Frozen Contract

Solidity contracts usually grow features as the project does, which is how you end up with an admin key, a pause function, and a roadmap-dependent supply. For Quantum Genesis I deliberately went the opposite direction: a contract small enough that "the team can't mess with it later" is a property you can eyeball.
The whole thing came out to a box of constraints: a fixed supply of 100 that no one can extend, batch minting to keep gas down, royalties enforced by the contract rather than by marketplace goodwill, metadata that freezes permanently, and zero admin keys. This is the build log — what I needed, the choices that followed, and the exact code. If you want the broader deployment context, the minting-on-Polygon post covers the why; this one is the how.
The requirements, non-negotiable
- 100 NFTs, fixed supply — no minting after deployment
- Batch minting — 10–20 NFTs per transaction to keep gas down
- 5% royalties enforced everywhere — OpenSea, Blur, LooksRare, any EIP-2981-compliant marketplace
- Metadata frozen on-chain — after all 100 are uploaded to IPFS, no changes possible
- Zero admin risk — no owner mint, no pause, no upgrade path, no URI changes
- Polygon deployment — cheap enough that 100 mints cost under $10 total
The contract skeleton
Standard OpenZeppelin building blocks, one twist: a _frozen flag that gates every mutating path.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Royalty.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract QuantumGenesis is ERC721, ERC721URIStorage, ERC721Royalty, Ownable {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
uint256 public constant MAX_SUPPLY = 100;
bool public _frozen = false;
constructor() ERC721("Quantum Genesis", "QGEN") Ownable(msg.sender) {
_setTokenRoyalty(0, msg.sender, 500); // 5% default (bps)
}
...
Batch minting and the gas math
A single NFT mint on Ethereum mainnet costs roughly 150,000 gas. On Polygon, through this batch function, it came out to ~35,000 gas per NFT:
function mintBatch(address[] calldata to, uint256[] calldata tokenIds)
external
onlyOwner
{
require(to.length == tokenIds.length, "Length mismatch");
require(to.length > 0 && to.length <= 20, "Batch 1-20");
for (uint256 i = 0; i < to.length; i++) {
uint256 tokenId = tokenIds[i];
require(tokenId > 0 && tokenId <= MAX_SUPPLY, "Invalid tokenId");
require(_owners(tokenId) == address(0), "Already minted");
_tokenIds.increment();
_safeMint(to[i], tokenId);
}
}
The final result: 100 NFTs in 5 transactions of 20, ~3.5M total gas. At 30 gwei MATIC that's roughly $8.40 for the entire collection.
Royalties without marketplace opt-in
No marketplace configuration required, because the contract itself speaks EIP-2981:
function royaltyInfo(uint256 tokenId, uint256 salePrice)
external
view
returns (address receiver, uint256 royaltyAmount)
{
require(_exists(tokenId), "Token does not exist");
return (msg.sender, (salePrice * 500) / 10000); // 5% = 500 bps
}
Any marketplace or indexer that reads royaltyInfo gets the same answer. There's no per-marketplace toggle for an owner to flip on or off.
Freezing the metadata
Once all 100 URIs are set and the files are pinned to IPFS, a single freeze() call makes the whole contract read-only:
function setTokenURI(uint256 tokenId, string calldata uri)
external
onlyOwner
{
require(!_frozen, "Contract frozen");
require(_exists(tokenId), "Token does not exist");
_setTokenURI(tokenId, uri);
}
function freeze() external onlyOwner {
_frozen = true;
}
function _setTokenURI(uint256 tokenId, string calldata uri)
internal
override(ERC721, ERC721URIStorage)
{
require(!_frozen, "Contract frozen");
super._setTokenURI(tokenId, uri);
}
After freeze(), nobody — not even the owner — can change any tokenURI. The provenance is permanent, which matters because those URIs point at the quantum certificates described in the provenance post.
What the owner actually can't do
The "no admin keys" claim decomposes into a list of decisions:
- No proxy pattern — logic and storage live in a single contract
- No pause function — the contract outright cannot be stopped
- No mint after deployment — the only mint path is the batch call in the deployment window
- Owner can only: withdraw royalties, set initial URIs (before freeze), transfer ownership
- No
setBaseURI— every token's URI is set individually at mint time, so a "base swap" attack has nothing to re-point
Deployment and verification
The sequence that put all of this live:
- Compile with Hardhat:
npx hardhat compile - Deploy:
npx hardhat run scripts/deploy.ts --network polygon - Verify on PolygonScan:
npx hardhat verify --network polygon CONTRACT_ADDRESS - Set all 100 URIs, then call
freeze() - Optional final touch:
renounceOwnership()— which we did via the deployer flow
Live contract: 0x488fCfaEA5fDf1cF6BAED5e8A34D7858033E1a27 on Polygon, verified.
Full source, deployment script, and test suite: github.com/marceloclaudecode01/quantum-art-lab/tree/main/contracts
The design constraint I'd steal for any NFT project: decide up front which state transitions you'll never want, and make them impossible by construction rather than by convention. If you're hand-rolling alternatives to parts of this, the web3.py deployment guide and the ERC-721 metadata reference are the two files I kept open while writing the deploy script.
Comments
Post a Comment