Writing a secure ERC-721 contract from scratch, without OpenZeppelin
When we started Quantum Genesis I made a deliberate call that most people would probably talk me out of: we would not use OpenZeppelin. Not because it's bad — it's the opposite, it's the gold standard, audited and battle-tested — but because this project had a very specific shape. A fixed supply of 100 tokens, minted only by the deployer, no marketplace features. Inheriting the full OZ stack for that felt like pulling in a library to flip one switch.
So we wrote every line of Solidity ourselves. This post runs through the contract's security decision-by-decision: why we skipped the library, the custom-error tradeoff, access control, reentrancy, the ERC-721 core, EIP-2981 royalties, and batch minting.
One honest caveat up front: for production contracts holding significant value, OpenZeppelin is the safer bet, full stop. This approach made sense for our small, fixed-supply collection where we controlled the whole minting process. Keep that context through everything below.

Why skip OpenZeppelin
Three reasons drove it:
- Bytecode size. OZ's ERC-721 pulls in a chain of inherited contracts — Context, ERC165, IERC721, IERC721Metadata, and more. For 100 tokens with no marketplace features, that's bloat. Smaller bytecode means a cheaper deployment on Polygon.
- No hidden complexity. Inheriting OZ means inheriting their assumptions.
ERC721Enumerableadds gas overhead to every transfer;Ownable's two-step transfer is a pattern we'd never use. I wanted to understand every opcode we deployed. - It fit the experiment. This whole project started with quantum computers making art. Writing the contract by hand matched the spirit.
Custom errors over strings
Solidity 0.8.4 introduced custom errors, which are dramatically cheaper than require(condition, "string") — a string lives in the bytecode and gets fully encoded into revert data, and every character costs gas. A custom error is a 4-byte function selector instead.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Custom errors — stored as 4-byte selectors, not strings
error NotOwner();
error TokenDoesNotExist(uint256 tokenId);
error TransferToZeroAddress();
error ApprovalToCurrentOwner();
error NotApprovedOrOwner();
error MintToZeroAddress();
error TokenAlreadyMinted(uint256 tokenId);
error MaxSupplyReached(uint256 maxSupply);
error InvalidRoyaltyFraction();
error BatchSizeTooLarge(uint256 requested, uint256 maximum);
// Compare gas costs:
// require(msg.sender == owner, "QuantumGenesis: caller is not the owner");
// ↑ ~24,000 gas for the string encoding
//
// if (msg.sender != _owner) revert NotOwner();
// ↑ ~2,400 gas for the selector
//
// That's a 10x reduction in revert gas costs
Since only the owner mints, most users never hit these errors anyway. But custom errors also keep the bytecode smaller (no string literals), which cuts deployment cost for everyone.
Access control without Ownable.sol
Modern OpenZeppelin Ownable uses a two-step transfer (propose + accept) to stop you from accidentally handing ownership to a wrong address. That's sensible for high-value protocols. We needed exactly one thing — only the deployer can mint — so we used the simplest ownership pattern that satisfies it:
contract QuantumGenesis {
address private _owner;
modifier onlyOwner() {
if (msg.sender != _owner) revert NotOwner();
_;
}
constructor() {
_owner = msg.sender;
}
function owner() public view returns (address) {
return _owner;
}
// No transferOwnership function.
// No renounceOwnership function.
// The deployer is the owner, forever.
// For a fixed-supply collection, this is sufficient.
}
Why no ownership transfer? Our supply is fixed at 100. Once all tokens are minted, the owner's only remaining privilege is... nothing. There's no admin function that needs to survive minting. If we wanted to add one later (say, updating baseURI), we'd have planned for it — but we don't.
The principle: minimal authority
Every state-modifying function should demand the minimum authorization needed. Our contract has exactly two owner-only state-modifying functions: mint and batchMint. Everything else (transfers, approvals) is governed by the ERC-721 rules.
Reentrancy protection
Reentrancy is the oldest and most famous smart contract bug. The 2016 DAO hack exploited it: your contract calls an external contract, which calls back into yours before the first call finishes, hitting inconsistent state.
For ERC-721, the risk lives in safeTransferFrom, which invokes onERC721Received on contract recipients. A malicious recipient could re-enter during that callback.
We used the checks-effects-interactions pattern — the simplest and most gas-efficient guard:
function _safeTransfer(
address from,
address to,
uint256 tokenId,
bytes memory data
) internal {
// CHECKS: Validate inputs
if (to == address(0)) revert TransferToZeroAddress();
// EFFECTS: Update all state BEFORE any external call
_owners[tokenId] = to;
_balances[from] -= 1;
_balances[to] += 1;
// Clear approvals
delete _tokenApprovals[tokenId];
emit Transfer(from, to, tokenId);
// INTERACTIONS: External call LAST
if (to.code.length > 0) {
try IERC721Receiver(to).onERC721Received(
msg.sender, from, tokenId, data
) returns (bytes4 retval) {
if (retval != IERC721Receiver.onERC721Received.selector) {
revert("ERC721: transfer to non-receiver");
}
} catch {
revert("ERC721: transfer to non-receiver");
}
}
}
By the time onERC721Received runs, all state is committed. Even if the recipient calls back, the token already belongs to the new owner. There's nothing left to exploit.
Why not a reentrancy guard? OpenZeppelin's ReentrancyGuard uses a storage mutex that costs ~5,000 gas per call for the SSTORE operations. Checks-effects-interactions gives the same protection at zero extra gas — the price is discipline. Every function must order its logic correctly. For a small contract, that's manageable.
The ERC-721 core
The standard requires these functions:
// Required by ERC-721
function balanceOf(address owner) external view returns (uint256);
function ownerOf(uint256 tokenId) external view returns (address);
function safeTransferFrom(address from, address to, uint256 tokenId, bytes data) external;
function safeTransferFrom(address from, address to, uint256 tokenId) external;
function transferFrom(address from, address to, uint256 tokenId) external;
function approve(address to, uint256 tokenId) external;
function setApprovalForAll(address operator, bool approved) external;
function getApproved(uint256 tokenId) external view returns (address);
function isApprovedForAll(address owner, address operator) external view returns (bool);
// Required by ERC-721 Metadata
function name() external view returns (string);
function symbol() external view returns (string);
function tokenURI(uint256 tokenId) external view returns (string);
The storage layout stays minimal:
// Core ERC-721 storage
mapping(uint256 => address) private _owners;
mapping(address => uint256) private _balances;
mapping(uint256 => address) private _tokenApprovals;
mapping(address => mapping(address => bool)) private _operatorApprovals;
// Collection metadata
string private _name = "Quantum Genesis";
string private _symbol = "QGEN";
string private _baseURI;
uint256 private _totalSupply;
uint256 public constant MAX_SUPPLY = 100;
The key security point in every public function is authorization. transferFrom must verify the caller is the owner, an approved address for that token, or an approved operator:
function _isApprovedOrOwner(address spender, uint256 tokenId)
internal view returns (bool)
{
address tokenOwner = _owners[tokenId];
if (tokenOwner == address(0)) revert TokenDoesNotExist(tokenId);
return (
spender == tokenOwner ||
_tokenApprovals[tokenId] == spender ||
_operatorApprovals[tokenOwner][spender]
);
}
EIP-2981 royalties
We implemented EIP-2981 so marketplaces can query the contract for on-chain royalty info:
// EIP-2981: NFT Royalty Standard
uint96 private constant ROYALTY_FEE = 500; // 5% (basis points: 500/10000)
address private _royaltyReceiver;
function royaltyInfo(uint256 /* tokenId */, uint256 salePrice)
external view returns (address receiver, uint256 royaltyAmount)
{
uint256 amount = (salePrice * ROYALTY_FEE) / 10000;
return (_royaltyReceiver, amount);
}
// ERC-165: declare support for EIP-2981
function supportsInterface(bytes4 interfaceId)
public pure returns (bool)
{
return
interfaceId == 0x80ac58cd || // ERC-721
interfaceId == 0x5b5e139f || // ERC-721 Metadata
interfaceId == 0x2a55205a || // EIP-2981
interfaceId == 0x01ffc9a7; // ERC-165
}
The royalty fraction is expressed in basis points (hundredths of a percent); 500 basis points = 5%. We validate it at construction so it can never exceed 100%:
constructor(string memory baseURI_, address royaltyReceiver_) {
if (ROYALTY_FEE > 10000) revert InvalidRoyaltyFraction();
_owner = msg.sender;
_baseURI = baseURI_;
_royaltyReceiver = royaltyReceiver_;
}
An important asterisk: EIP-2981 is informational only. Marketplaces choose to honor it, and OpenSea does — that's why we implemented it. But there's no on-chain enforcement; a marketplace could ignore it entirely.
Batch minting safety
Minting 100 tokens one at a time is tedious and gas-wasteful. Batch minting introduces its own risk: gas limits. Mint too many in one transaction and you hit the block gas limit, reverting the whole transaction — and you already paid for that gas.
uint256 public constant MAX_BATCH_SIZE = 20;
function batchMint(address to, uint256 startId, uint256 count)
external onlyOwner
{
if (to == address(0)) revert MintToZeroAddress();
if (count > MAX_BATCH_SIZE) revert BatchSizeTooLarge(count, MAX_BATCH_SIZE);
if (_totalSupply + count > MAX_SUPPLY) revert MaxSupplyReached(MAX_SUPPLY);
for (uint256 i = 0; i < count; ) {
uint256 tokenId = startId + i;
if (_owners[tokenId] != address(0)) revert TokenAlreadyMinted(tokenId);
_owners[tokenId] = to;
emit Transfer(address(0), to, tokenId);
unchecked { ++i; }
}
// Update balance once, not per token
_balances[to] += count;
_totalSupply += count;
}
The safety measures:
- MAX_BATCH_SIZE = 20 caps any single transaction. We minted in 5 batches of 20.
- Supply check before the loop prevents partial minting that would leave state inconsistent.
- Per-token existence check prevents double-minting if batch ranges ever overlap.
uncheckedincrement — the loop variable can't overflow a uint256 bounded byMAX_BATCH_SIZE, so we skip the overflow check for gas.- Single balance update — instead of incrementing
_balances[to]inside the loop, we do it once after. That saves ~2,100 gas per token (cold vs warm SSTORE).
Notice batchMint doesn't call onERC721Received. Since we're minting to our own EOA wallet, not a contract, the safe-transfer check is unnecessary — it saves gas and removes the reentrancy surface from the minting path entirely.
Deployment on Polygon
We deployed with web3.py:
# deploy_and_mint.py (simplified)
from web3 import Web3
import json
w3 = Web3(Web3.HTTPProvider("https://polygon-rpc.com"))
with open("QuantumGenesis.json") as f:
contract_data = json.load(f)
contract = w3.eth.contract(
abi=contract_data["abi"],
bytecode=contract_data["bytecode"]
)
# Deploy
tx = contract.constructor(
"ipfs://bafybeifges7tei5x7drj37f34yhzqofwlz2icbo7z67isg6g446k65yw3a/",
deployer_address # royalty receiver
).build_transaction({
"from": deployer_address,
"nonce": w3.eth.get_transaction_count(deployer_address),
"gasPrice": w3.eth.gas_price,
})
signed = w3.eth.account.sign_transaction(tx, private_key)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
Polygon was the obvious home: fractions of a cent per transaction, EVM-compatible, fully supported by OpenSea. Total deployment cost came in under $0.10, and minting all 100 tokens across the 5 batch transactions totaled under $0.50.
The security checklist
| Vulnerability | Mitigation |
|---|---|
| Reentrancy | Checks-effects-interactions pattern |
| Unauthorized minting | onlyOwner modifier |
| Integer overflow | Solidity 0.8.x built-in checks |
| Gas griefing (batch) | MAX_BATCH_SIZE = 20 |
| Supply overflow | MAX_SUPPLY constant + pre-check |
| Double minting | Per-token existence check |
| Transfer to zero | Explicit zero-address check |
| Approval to self | Check prevents approving current owner |

Is this contract as battle-tested as OpenZeppelin? No, and I won't pretend otherwise. But for a 100-token, fixed-supply art collection on Polygon, it's secure, gas-efficient, and fully transparent. It's verified on Polygonscan, so anyone can read every line. If you find a flaw in any of these decisions, I'd rather hear it now than later.
Comments
Post a Comment