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.

Quantum Genesis NFT #20 — owned by a hand-rolled ERC-721

Why skip OpenZeppelin

Three reasons drove it:

  1. 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.
  2. No hidden complexity. Inheriting OZ means inheriting their assumptions. ERC721Enumerable adds 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.
  3. 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.
  • unchecked increment — the loop variable can't overflow a uint256 bounded by MAX_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

VulnerabilityMitigation
ReentrancyChecks-effects-interactions pattern
Unauthorized mintingonlyOwner modifier
Integer overflowSolidity 0.8.x built-in checks
Gas griefing (batch)MAX_BATCH_SIZE = 20
Supply overflowMAX_SUPPLY constant + pre-check
Double mintingPer-token existence check
Transfer to zeroExplicit zero-address check
Approval to selfCheck prevents approving current owner

Quantum Genesis NFT #46 — A fixed-supply contract with no admin keys, no dependencies

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

Popular posts from this blog

Getting Your Collection Visible on OpenSea, Step by Step

Polygon versus Ethereum for an NFT contract, from the gas bills up

Quantum Error Correction, or Why Your Qubits Forget What They Were Doing