IAMUVIN

NFT & Digital Assets

NFT Smart Contract Development: ERC-721 and ERC-1155 Guide

Uvin Vindula·June 3, 2024·12 min read
Share

TL;DR

I have built NFT platforms for clients using ERC-721, ERC-1155, and ERC-721A across Ethereum, Arbitrum, and Base. This guide covers everything I use in production: choosing between ERC-721 and ERC-1155, writing secure mint functions, generating on-chain SVG metadata, batch minting with ERC-721A for gas savings, implementing ERC-2981 royalties, and the security checklist I run before every mainnet deployment. Every code example in this article comes from real contracts I have shipped. No hype, no speculation — just the engineering.


ERC-721 vs ERC-1155 — When to Use Each

The first decision in any NFT smart contract development project is which standard to use. I have deployed both in production, and the choice is never about which is "better." It is about what your project actually needs.

ERC-721 assigns a unique token ID to every single NFT. One token, one owner. This is the standard behind most PFP collections, 1/1 art pieces, and any project where every token must be individually distinguishable on-chain. Every CryptoPunk and every Bored Ape is an ERC-721 token.

ERC-1155 is a multi-token standard. A single contract can manage both fungible and non-fungible tokens. Token ID 1 might have a supply of 10,000 (fungible), while token ID 2 has a supply of 1 (non-fungible). This is the standard for gaming items, event tickets, and edition-based art.

Here is how I decide for client projects:

FactorERC-721ERC-1155
Token uniquenessEvery token is uniqueTokens can have editions
Gas per mintHigher (one storage slot per token)Lower (batch operations native)
Batch transfersNot native (requires loops)Native safeBatchTransferFrom
Marketplace supportUniversalUniversal (since 2023)
Metadata flexibilityStandard tokenURI per tokenURI with {id} substitution
Use casePFPs, 1/1 art, unique assetsGaming items, editions, tickets

My rule: if every token is a unique asset with unique metadata, use ERC-721. If you need editions, multiple token types in one contract, or batch operations, use ERC-1155. If gas cost per mint is your primary concern and you are doing a large collection, use ERC-721A (covered below).

If you need help choosing the right standard for your project, check out my Web3 development services.

Writing an ERC-721 Contract

I start every NFT project with OpenZeppelin Contracts. Their ERC-721 implementation has been audited extensively and handles the edge cases you would miss writing from scratch — safe transfers, approval management, and receiver validation.

Project setup with Foundry:

bash
mkdir nft-project && cd nft-project
forge init
forge install OpenZeppelin/openzeppelin-contracts

Add the remappings in foundry.toml:

toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = ["@openzeppelin/=lib/openzeppelin-contracts/"]

Here is the ERC-721 contract I use as a starting point for client projects:

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";

contract IAMUVINCollection is ERC721Enumerable, ERC2981, Ownable {
    using Strings for uint256;

    uint256 public constant MAX_SUPPLY = 5000;
    uint256 public constant MINT_PRICE = 0.05 ether;
    uint256 public constant MAX_PER_TX = 5;

    uint256 private _nextTokenId;
    string private _baseTokenURI;
    bool public mintActive;

    error MintInactive();
    error ExceedsMaxSupply();
    error ExceedsMaxPerTx();
    error InsufficientPayment();
    error WithdrawFailed();

    constructor(
        string memory name,
        string memory symbol,
        string memory baseURI,
        address royaltyReceiver
    ) ERC721(name, symbol) Ownable(msg.sender) {
        _baseTokenURI = baseURI;
        _setDefaultRoyalty(royaltyReceiver, 500); // 5%
    }

    function mint(uint256 quantity) external payable {
        if (!mintActive) revert MintInactive();
        if (quantity > MAX_PER_TX) revert ExceedsMaxPerTx();
        if (_nextTokenId + quantity > MAX_SUPPLY) revert ExceedsMaxSupply();
        if (msg.value < MINT_PRICE * quantity) revert InsufficientPayment();

        for (uint256 i; i < quantity; ) {
            _safeMint(msg.sender, _nextTokenId);
            unchecked {
                ++_nextTokenId;
                ++i;
            }
        }
    }

    function setMintActive(bool active) external onlyOwner {
        mintActive = active;
    }

    function setBaseURI(string calldata baseURI) external onlyOwner {
        _baseTokenURI = baseURI;
    }

    function withdraw() external onlyOwner {
        (bool success, ) = payable(owner()).call{value: address(this).balance}("");
        if (!success) revert WithdrawFailed();
    }

    function _baseURI() internal view override returns (string memory) {
        return _baseTokenURI;
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721Enumerable, ERC2981)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

Key decisions in this contract:

  1. Custom errors over require strings. Custom errors save gas. revert MintInactive() costs less than require(mintActive, "Mint not active") because the error selector is only 4 bytes versus a full string stored in bytecode.
  1. Unchecked increment in the loop. The loop counter and token ID cannot realistically overflow a uint256, so unchecked saves the overflow check gas on every iteration.
  1. `_safeMint` over `_mint`. _safeMint calls onERC721Received on the recipient if it is a contract. This prevents tokens from being permanently locked in contracts that cannot handle them. Always use _safeMint unless you have a specific reason not to.
  1. Constants for immutable values. MAX_SUPPLY, MINT_PRICE, and MAX_PER_TX are compile-time constants. They cost zero gas to read because the compiler inlines their values.

On-Chain Metadata with Dynamic SVG

Off-chain metadata hosted on IPFS is the standard approach, but for projects that need fully on-chain assets — generative art, dynamic NFTs that change based on on-chain state, or collections that must survive regardless of any external service — I generate SVG directly in the contract.

The approach: override tokenURI to return a data:application/json;base64 URI containing a JSON metadata object, where the image field is a data:image/svg+xml;base64 encoded SVG.

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";

contract OnChainNFT is ERC721, Ownable {
    using Strings for uint256;

    uint256 private _nextTokenId;

    constructor() ERC721("OnChainArt", "OCA") Ownable(msg.sender) {}

    function mint() external {
        _safeMint(msg.sender, _nextTokenId);
        unchecked { ++_nextTokenId; }
    }

    function tokenURI(uint256 tokenId)
        public
        view
        override
        returns (string memory)
    {
        _requireOwned(tokenId);

        string memory svg = _generateSVG(tokenId);
        string memory svgBase64 = Base64.encode(bytes(svg));

        string memory json = string.concat(
            '{"name":"OnChainArt #',
            tokenId.toString(),
            '","description":"Fully on-chain generative art."',
            ',"image":"data:image/svg+xml;base64,',
            svgBase64,
            '","attributes":[{"trait_type":"Seed","value":"',
            tokenId.toString(),
            '"}]}'
        );

        return string.concat(
            "data:application/json;base64,",
            Base64.encode(bytes(json))
        );
    }

    function _generateSVG(uint256 tokenId)
        internal
        pure
        returns (string memory)
    {
        uint256 hue = (tokenId * 137) % 360;
        uint256 cx = 50 + (tokenId * 73) % 200;
        uint256 cy = 50 + (tokenId * 91) % 200;
        uint256 radius = 20 + (tokenId * 53) % 80;

        return string.concat(
            '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">',
            '<rect width="300" height="300" fill="#0A0E1A"/>',
            '<circle cx="',
            cx.toString(),
            '" cy="',
            cy.toString(),
            '" r="',
            radius.toString(),
            '" fill="hsl(',
            hue.toString(),
            ', 80%, 60%)"/>',
            "</svg>"
        );
    }
}

Important considerations for on-chain SVG:

  • Gas cost scales with SVG complexity. Every byte of SVG stored or generated on-chain costs gas. Keep SVGs minimal. Use shapes and gradients instead of paths when possible.
  • Use `string.concat` over `abi.encodePacked` for string operations. It is more readable and produces the same bytecode.
  • Deterministic generation. I derive all visual properties from the token ID. This means the same token always renders the same image. No randomness needed, no oracle dependency.
  • Test the output. Decode the base64 tokenURI and verify the JSON parses correctly and the SVG renders in a browser before deploying.

Batch Minting with ERC-721A

For large collections (5,000+ tokens), the gas cost of minting multiple ERC-721 tokens in a single transaction is painful. Each _safeMint call writes to storage for ownership and balance. Minting 5 tokens costs roughly 5x the gas of minting 1.

ERC-721A, created by the Azuki team, solves this by storing ownership data lazily. Instead of writing ownership for every token, it writes ownership once for the first token in a batch. Subsequent tokens in the batch inherit ownership from the first until they are transferred.

bash
forge install chiru-labs/ERC721A
solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {ERC721A} from "erc721a/contracts/ERC721A.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract OptimizedCollection is ERC721A, Ownable {
    uint256 public constant MAX_SUPPLY = 10000;
    uint256 public constant MINT_PRICE = 0.03 ether;
    uint256 public constant MAX_PER_TX = 10;

    string private _baseTokenURI;
    bool public mintActive;

    error MintInactive();
    error ExceedsMaxSupply();
    error ExceedsMaxPerTx();
    error InsufficientPayment();
    error WithdrawFailed();

    constructor(
        string memory baseURI
    ) ERC721A("OptimizedNFT", "ONFT") Ownable(msg.sender) {
        _baseTokenURI = baseURI;
    }

    function mint(uint256 quantity) external payable {
        if (!mintActive) revert MintInactive();
        if (quantity > MAX_PER_TX) revert ExceedsMaxPerTx();
        if (_totalMinted() + quantity > MAX_SUPPLY) revert ExceedsMaxSupply();
        if (msg.value < MINT_PRICE * quantity) revert InsufficientPayment();

        _mint(msg.sender, quantity);
    }

    function setMintActive(bool active) external onlyOwner {
        mintActive = active;
    }

    function setBaseURI(string calldata baseURI) external onlyOwner {
        _baseTokenURI = baseURI;
    }

    function withdraw() external onlyOwner {
        (bool success, ) = payable(owner()).call{value: address(this).balance}("");
        if (!success) revert WithdrawFailed();
    }

    function _baseURI() internal view override returns (string memory) {
        return _baseTokenURI;
    }

    function _startTokenId() internal pure override returns (uint256) {
        return 1;
    }
}

Gas comparison from my benchmarks on a 10,000 supply collection:

QuantityERC-721 GasERC-721A GasSavings
1 token~93,000~91,000~2%
5 tokens~393,000~113,000~71%
10 tokens~783,000~135,000~83%

The savings are dramatic at higher quantities. The tradeoff: the first transfer of a token that has not been explicitly written to storage costs more gas because it needs to resolve ownership by scanning backwards. For most use cases, this tradeoff is worth it.

Key differences from standard ERC-721:

  • Use _totalMinted() instead of totalSupply() for supply checks. totalSupply() in ERC-721A accounts for burned tokens, which adds unnecessary complexity to mint guards.
  • _mint instead of _safeMint. ERC-721A's _mint already includes the safe transfer check internally.
  • Override _startTokenId() to start at 1 instead of 0. Most marketplaces and users expect token IDs to start at 1.

Royalties with ERC-2981

ERC-2981 is the on-chain royalty standard. It defines a royaltyInfo function that marketplaces call to determine the royalty amount and recipient for any sale. While marketplace enforcement varies, implementing ERC-2981 is the baseline expectation for any professional NFT contract.

OpenZeppelin's ERC2981 implementation handles the math. You configure it in the constructor:

solidity
// In the constructor:
_setDefaultRoyalty(royaltyReceiver, 500); // 5% = 500 basis points

// For per-token royalties (rare, but useful for collaborative collections):
_setTokenRoyalty(tokenId, artistAddress, 750); // 7.5% for this specific token

The royaltyInfo function returns:

solidity
function royaltyInfo(uint256 tokenId, uint256 salePrice)
    external
    view
    returns (address receiver, uint256 royaltyAmount);

If salePrice is 1 ETH and royalty is 5%, it returns the receiver address and 0.05 ETH.

Important: you must override supportsInterface when combining ERC-2981 with ERC-721 or ERC-721Enumerable, because both define supportsInterface. Without the override, the compiler will reject the contract.

solidity
function supportsInterface(bytes4 interfaceId)
    public
    view
    override(ERC721Enumerable, ERC2981)
    returns (bool)
{
    return super.supportsInterface(interfaceId);
}

Writing an ERC-1155 Contract

For projects that need multiple token types — game items with different rarities, event tickets with different tiers, or edition-based art — ERC-1155 is the right choice.

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import {ERC1155Supply} from "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";

contract MultiTokenCollection is ERC1155Supply, Ownable {
    using Strings for uint256;

    string public name;
    string public symbol;

    struct TokenConfig {
        uint256 maxSupply;
        uint256 price;
        bool active;
    }

    mapping(uint256 => TokenConfig) public tokenConfigs;

    error TokenNotActive();
    error ExceedsMaxSupply();
    error InsufficientPayment();
    error InvalidTokenId();
    error WithdrawFailed();

    constructor(
        string memory _name,
        string memory _symbol,
        string memory baseURI
    ) ERC1155(baseURI) Ownable(msg.sender) {
        name = _name;
        symbol = _symbol;
    }

    function configureToken(
        uint256 tokenId,
        uint256 maxSupply,
        uint256 price,
        bool active
    ) external onlyOwner {
        tokenConfigs[tokenId] = TokenConfig({
            maxSupply: maxSupply,
            price: price,
            active: active
        });
    }

    function mint(uint256 tokenId, uint256 quantity) external payable {
        TokenConfig memory config = tokenConfigs[tokenId];
        if (!config.active) revert TokenNotActive();
        if (totalSupply(tokenId) + quantity > config.maxSupply) {
            revert ExceedsMaxSupply();
        }
        if (msg.value < config.price * quantity) {
            revert InsufficientPayment();
        }

        _mint(msg.sender, tokenId, quantity, "");
    }

    function mintBatch(
        uint256[] calldata tokenIds,
        uint256[] calldata quantities
    ) external payable {
        uint256 totalCost;
        for (uint256 i; i < tokenIds.length; ) {
            TokenConfig memory config = tokenConfigs[tokenIds[i]];
            if (!config.active) revert TokenNotActive();
            if (totalSupply(tokenIds[i]) + quantities[i] > config.maxSupply) {
                revert ExceedsMaxSupply();
            }
            totalCost += config.price * quantities[i];
            unchecked { ++i; }
        }
        if (msg.value < totalCost) revert InsufficientPayment();

        _mintBatch(msg.sender, tokenIds, quantities, "");
    }

    function uri(uint256 tokenId)
        public
        view
        override
        returns (string memory)
    {
        return string.concat(super.uri(tokenId), tokenId.toString(), ".json");
    }

    function withdraw() external onlyOwner {
        (bool success, ) = payable(owner()).call{value: address(this).balance}("");
        if (!success) revert WithdrawFailed();
    }
}

The ERC-1155 advantage shows up in batch operations. Minting 5 different token types in a single mintBatch call is significantly cheaper than 5 separate transactions. Similarly, safeBatchTransferFrom lets users transfer multiple token types in one transaction — critical for gaming use cases where a player might trade an entire inventory.

Security Considerations

Every NFT contract I deploy goes through the same security review. These are the vulnerabilities I check for, in order of how often I see them in audit reports:

1. Reentrancy on mint functions. _safeMint calls onERC721Received on the recipient, which executes external code. If your mint function updates state after the mint call, a malicious contract can reenter and mint beyond limits.

solidity
// VULNERABLE: state update after external call
function mint(uint256 quantity) external payable {
    _safeMint(msg.sender, _nextTokenId); // external call
    _nextTokenId++; // state update after call - WRONG
}

// SAFE: state update before external call (Checks-Effects-Interactions)
function mint(uint256 quantity) external payable {
    uint256 tokenId = _nextTokenId; // cache
    _nextTokenId++; // state update first
    _safeMint(msg.sender, tokenId); // external call last
}

2. Integer overflow on price calculation. If MINT_PRICE * quantity overflows, the check passes with a tiny payment. Solidity 0.8+ has built-in overflow checks, but be careful with unchecked blocks.

3. Missing access control on admin functions. Every setBaseURI, setMintActive, withdraw, and configureToken function must be onlyOwner or behind proper role-based access control.

4. Unrestricted mint quantity. Always enforce a MAX_PER_TX limit. Without it, a single wallet can mint the entire supply in one transaction, denying access to other users and potentially causing gas issues.

5. Front-running on reveal. If metadata is set on-chain before or during mint, miners and MEV bots can see which token IDs have rare traits and selectively mint those. Use a commit-reveal scheme or set metadata after mint completes.

6. Withdraw function failure. If the owner is a contract that rejects ETH (no receive/fallback function), the withdraw function fails permanently, locking funds. Always use a pull pattern or ensure the owner can receive ETH.

solidity
// SAFER: pull-based withdraw with explicit recipient
function withdraw(address payable recipient) external onlyOwner {
    (bool success, ) = recipient.call{value: address(this).balance}("");
    if (!success) revert WithdrawFailed();
}

Testing Your NFT Contract

I test every NFT contract with Foundry. Fuzz testing catches edge cases that unit tests miss. Here is the test structure I use:

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {Test} from "forge-std/Test.sol";
import {IAMUVINCollection} from "../src/IAMUVINCollection.sol";

contract IAMUVINCollectionTest is Test {
    IAMUVINCollection public nft;
    address public owner = makeAddr("owner");
    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");

    function setUp() public {
        vm.prank(owner);
        nft = new IAMUVINCollection(
            "TestNFT",
            "TNFT",
            "https://api.example.com/metadata/",
            owner
        );
        vm.prank(owner);
        nft.setMintActive(true);
    }

    function test_MintSingle() public {
        vm.deal(alice, 1 ether);
        vm.prank(alice);
        nft.mint{value: 0.05 ether}(1);

        assertEq(nft.balanceOf(alice), 1);
        assertEq(nft.ownerOf(0), alice);
    }

    function test_MintMultiple() public {
        vm.deal(alice, 1 ether);
        vm.prank(alice);
        nft.mint{value: 0.25 ether}(5);

        assertEq(nft.balanceOf(alice), 5);
    }

    function test_RevertWhen_MintExceedsMaxPerTx() public {
        vm.deal(alice, 10 ether);
        vm.prank(alice);
        vm.expectRevert(IAMUVINCollection.ExceedsMaxPerTx.selector);
        nft.mint{value: 0.3 ether}(6);
    }

    function test_RevertWhen_InsufficientPayment() public {
        vm.deal(alice, 1 ether);
        vm.prank(alice);
        vm.expectRevert(IAMUVINCollection.InsufficientPayment.selector);
        nft.mint{value: 0.01 ether}(1);
    }

    function test_RevertWhen_MintInactive() public {
        vm.prank(owner);
        nft.setMintActive(false);

        vm.deal(alice, 1 ether);
        vm.prank(alice);
        vm.expectRevert(IAMUVINCollection.MintInactive.selector);
        nft.mint{value: 0.05 ether}(1);
    }

    function test_Withdraw() public {
        vm.deal(alice, 1 ether);
        vm.prank(alice);
        nft.mint{value: 0.25 ether}(5);

        uint256 balanceBefore = owner.balance;
        vm.prank(owner);
        nft.withdraw();

        assertEq(owner.balance, balanceBefore + 0.25 ether);
    }

    function test_RoyaltyInfo() public {
        (address receiver, uint256 amount) = nft.royaltyInfo(0, 1 ether);
        assertEq(receiver, owner);
        assertEq(amount, 0.05 ether); // 5%
    }

    // Fuzz test: any valid quantity should mint correctly
    function testFuzz_Mint(uint256 quantity) public {
        quantity = bound(quantity, 1, 5);
        uint256 cost = 0.05 ether * quantity;

        vm.deal(alice, cost);
        vm.prank(alice);
        nft.mint{value: cost}(quantity);

        assertEq(nft.balanceOf(alice), quantity);
    }

    // Fuzz test: any payment below required should revert
    function testFuzz_RevertWhen_Underpaying(uint256 payment) public {
        payment = bound(payment, 0, 0.05 ether - 1);

        vm.deal(alice, payment);
        vm.prank(alice);
        vm.expectRevert(IAMUVINCollection.InsufficientPayment.selector);
        nft.mint{value: payment}(1);
    }
}

Run the tests:

bash
forge test -vvv

Run with gas reporting:

bash
forge test --gas-report

The fuzz tests are critical. testFuzz_Mint verifies that any valid quantity mints correctly. testFuzz_RevertWhen_Underpaying verifies that any insufficient payment reverts. Foundry runs these with hundreds of random inputs by default — you can increase this with FOUNDRY_FUZZ_RUNS=10000.

Deployment Checklist

This is the checklist I run through before every mainnet NFT deployment. I have never shipped a contract without completing every item.

Pre-deployment:

  • [ ] All Foundry tests pass with forge test
  • [ ] Fuzz tests run with at least 10,000 iterations
  • [ ] Gas report reviewed, mint cost within budget
  • [ ] supportsInterface returns true for ERC-721, ERC-2981, and ERC-165
  • [ ] tokenURI returns valid JSON with correct image and attributes
  • [ ] Owner functions are access-controlled
  • [ ] Withdraw function tested with both EOA and contract owners
  • [ ] No compiler warnings in forge build --force
  • [ ] Slither static analysis clean: slither src/

Testnet deployment:

  • [ ] Deploy to Sepolia (or relevant L2 testnet)
  • [ ] Mint tokens and verify metadata on OpenSea testnet
  • [ ] Test all admin functions
  • [ ] Verify contract source on Etherscan
  • [ ] Test royalty info returns correct values
bash
# Deploy to Sepolia
forge create src/IAMUVINCollection.sol:IAMUVINCollection \
    --rpc-url $SEPOLIA_RPC \
    --private-key $DEPLOYER_KEY \
    --constructor-args "CollectionName" "SYM" "https://api.example.com/metadata/" $ROYALTY_RECEIVER \
    --verify \
    --etherscan-api-key $ETHERSCAN_KEY

Mainnet deployment:

  • [ ] Testnet deployment verified and tested for at least 48 hours
  • [ ] Contract audited (internal + external for high-value projects)
  • [ ] Emergency pause mechanism working (for projects that need it)
  • [ ] Multi-sig configured for owner role (Gnosis Safe)
  • [ ] Gas price checked — deploy during low gas periods
  • [ ] Constructor arguments double-checked
  • [ ] Deployment transaction simulated with forge script --fork-url
bash
# Deploy to mainnet
forge create src/IAMUVINCollection.sol:IAMUVINCollection \
    --rpc-url $MAINNET_RPC \
    --private-key $DEPLOYER_KEY \
    --constructor-args "CollectionName" "SYM" "https://api.example.com/metadata/" $ROYALTY_RECEIVER \
    --verify \
    --etherscan-api-key $ETHERSCAN_KEY

Post-deployment:

  • [ ] Contract verified on Etherscan
  • [ ] Ownership transferred to multi-sig
  • [ ] Test mint on mainnet
  • [ ] Metadata renders correctly on OpenSea, Blur, and LooksRare
  • [ ] Royalty configuration verified on marketplace

Key Takeaways

  1. Choose the right standard. ERC-721 for unique assets. ERC-1155 for editions and multi-token contracts. ERC-721A for large collections where mint gas matters.
  1. On-chain SVG is powerful but expensive. Use it for generative art and dynamic NFTs. Keep SVGs minimal. Test the base64 output before deploying.
  1. Gas optimization is real money. ERC-721A saves 70-80% on batch mints. Custom errors save gas over require strings. Constants are free to read.
  1. Security is not optional. Checks-Effects-Interactions on every mint. Access control on every admin function. Fuzz testing catches what unit tests miss.
  1. Test like your contract holds real money — because it will. Foundry fuzz tests with 10,000+ runs. Slither static analysis. Testnet deployment for 48 hours minimum.
  1. ERC-2981 royalties are table stakes. Every professional NFT contract implements on-chain royalties. The code is minimal — there is no excuse to skip it.

*I am Uvin Vindula (@IAMUVIN), a Web3 and AI engineer based in Sri Lanka and the UK. I build NFT platforms, DeFi protocols, and production smart contracts for clients across Ethereum, Arbitrum, and Base. Every contract I ship goes through the full audit and testing pipeline described in this guide. If you are building an NFT project and need engineering support, reach out through my services page or at contact@uvin.lk.*

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.