Writing a Secure ERC-721 Smart Contract Without OpenZeppelin

When we built the smart contract for Quantum Genesis — our 100-NFT collection generated by real quantum computers — we made a deliberate choice: no OpenZeppelin. We wrote every line of Solidity ourselves.

This post explains why, and walks through every security decision we made along the way.

Why We Skipped OpenZeppelin

OpenZeppelin is the gold standard for smart contract libraries. It's audited, battle-tested, and used by most NFT projects. So why skip it?

Three reasons:

  1. Bytecode size. OpenZeppelin's ERC-721 implementation pulls in a chain of inherited contracts: Context, ERC165, IERC721, IERC721Metadata, and more. For a simple 100-token collection with no marketplace features, that's unnecessary bloat. Smaller bytecode means lower deployment gas costs on Polygon.
  2. No hidden complexity. When you inherit from OpenZeppelin, you inherit assumptions. Their ERC721Enumerable adds gas overhead to every transfer. Their Ownable uses a two-step transfer pattern you might not need. We wanted to understand every opcode we were deploying.
  3. Learning. This project started as an experiment — quantum computers making art. Writing the contract from scratch fit the experimental spirit.
Disclaimer: For production contracts handling significant value, OpenZeppelin is the safer choice. Their code is audited by professional security firms. Our approach made sense for a small, fixed-supply collection where we controlled the entire minting process.

Custom Errors: Cheaper Than Strings

Solidity 0.8.4 introduced custom errors, which are dramatically cheaper than require(condition, "string message"). The string gets stored in the contract bytecode and included in revert data — every character costs gas.

Custom errors use a function selector (4 bytes) instead of an ABI-encoded string. The savings are significant:

// 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

For a collection where only the owner mints, most users will never trigger these errors. But custom errors also make the contract smaller (no string literals in bytecode), which reduces deployment costs for everyone.

Quantum Genesis NFT #1 - the first NFT minted from the custom ERC-721 contract

Quantum Genesis #1 — the first token minted through our custom contract on Polygon.

Access Control Without Ownable.sol

OpenZeppelin's Ownable.sol has evolved over the years. Recent versions use a two-step ownership transfer (propose + accept) to prevent accidentally transferring ownership to a wrong address. That's great for high-value protocols.

For Quantum Genesis, we needed exactly one thing: only the deployer can mint. We implemented the simplest possible ownership pattern:

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? Because our collection has a fixed supply of 100. Once all tokens are minted, the owner's only remaining privilege is... nothing. There's no admin function that needs to persist after minting. If we wanted to add one later (like updating base URI), we'd need to plan for it — but we don't.

The Principle: Minimal Authority

Every function that can modify state should require the minimum necessary authorization. Our contract has exactly two state-modifying owner functions: mint and batchMint. Everything else (transfers, approvals) is governed by the ERC-721 standard rules.

Reentrancy Protection

Reentrancy is the oldest and most famous smart contract vulnerability. The 2016 DAO hack exploited it. The pattern is simple: your contract calls an external contract, which calls back into your contract before the first call finishes, finding state in an inconsistent state.

For ERC-721, reentrancy risk exists in the safeTransferFrom function, which calls onERC721Received on the recipient if it's a contract. A malicious recipient could re-enter during that callback.

We used the checks-effects-interactions pattern — the simplest and most gas-efficient reentrancy protection:

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 the external call (onERC721Received) executes, all state changes are already committed. Even if the recipient calls back into our contract, the state is consistent. The token already belongs to the new owner. There's nothing to exploit.

Why not use a reentrancy guard? OpenZeppelin's ReentrancyGuard uses a mutex (storage variable toggle). That costs ~5,000 gas per call for the SSTORE operations. The checks-effects-interactions pattern achieves the same protection at zero extra gas cost. The tradeoff: you must be disciplined about ordering. Every function must follow the pattern correctly. For a small contract, that's manageable.

Implementing ERC-721 Core

The ERC-721 standard (EIP-721) 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);

Our storage layout is 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 consideration in every public function is authorization. The transferFrom function must verify that the caller is either the owner, an approved address for that specific token, or an approved operator for the owner:

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 for on-chain royalty information. This standard allows marketplaces to query the contract for royalty details:

// 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 uses basis points (hundredths of a percent). 500 basis points = 5%. We validate this at construction time to ensure it never exceeds 100%:

constructor(string memory baseURI_, address royaltyReceiver_) {
    if (ROYALTY_FEE > 10000) revert InvalidRoyaltyFraction();
    _owner = msg.sender;
    _baseURI = baseURI_;
    _royaltyReceiver = royaltyReceiver_;
}
Quantum Genesis NFT #55 - abstract quantum art on the Polygon blockchain

Quantum Genesis #55 — each sale generates 5% royalties via EIP-2981.

Important note: EIP-2981 is informational only. Marketplaces choose to honor it. OpenSea respects EIP-2981, which is why we implemented it. But there's no on-chain enforcement — a marketplace could ignore the royalty entirely.

Batch Minting Safety

We minted 100 NFTs, and doing it one at a time would have been tedious and gas-wasteful. Batch minting introduces a specific risk: gas limit. If you try to mint too many tokens in a single transaction, you'll hit the block gas limit and the entire transaction reverts (wasting the gas you already spent).

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;
}

Key safety measures:

  • MAX_BATCH_SIZE = 20: Caps a single transaction. We minted in 5 batches of 20.
  • Supply check before the loop: Prevents partial minting that would leave state inconsistent.
  • Duplicate check per token: Prevents accidental double-minting if batch ranges overlap.
  • unchecked increment: The loop variable i can't overflow a uint256 when bounded by MAX_BATCH_SIZE, so we skip the overflow check for gas savings.
  • Single balance update: Instead of incrementing _balances[to] inside the loop, we do it once after. Saves ~2,100 gas per token (cold SSTORE vs warm SSTORE).

No safeTransfer in Minting

Notice that batchMint doesn't call onERC721Received. Since we're minting to our own wallet (an EOA, not a contract), the safe transfer check is unnecessary. This saves gas and eliminates the reentrancy surface in the minting path entirely.

Deployment on Polygon

We deployed using web3.py with a Python script. Polygon was the obvious choice: low gas fees (fractions of a cent per transaction), EVM-compatible, and fully supported by OpenSea.

# 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)

Total deployment cost on Polygon: less than $0.10. Minting all 100 tokens in 5 batch transactions: less than $0.50 total. Try doing that on Ethereum mainnet.

Security Checklist Summary

Here's every security measure in our contract, summarized:

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

Is this contract as battle-tested as OpenZeppelin? No. But for a 100-token, fixed-supply art collection on Polygon, it's secure, gas-efficient, and fully transparent.

The contract is verified on Polygonscan. You can read every line yourself.

@media (prefers-color-scheme: dark) { background:#1a1a2e; border-color:#2d2d4a; box-shadow:0 1px 3px rgba(0,0,0,0.3); }
Marcelo Santos
Marcelo Santos
Engenheiro Quântico • Artista Generativo • Founder Quantum Art Lab

Desenvolvo sistemas que usam hardware quântico real (IBM Quantum, Origin Quantum) para gerar arte e NFTs com proveniência verificável on-chain. Escrevo sobre computação quântica aplicada, criptografia, Web3 e arte generativa — tudo com código que roda em processadores quânticos de verdade.

Comentários

Postagens mais visitadas deste blog

How to List NFTs on OpenSea: The Complete 2026 Guide

Polygon vs Ethereum for NFTs: Why We Chose Polygon (and Saved $490)

Quantum Error Correction Explained: Why Your Qubits Need Backup