Intermediate

ERC-721 NFT Implementation — Hands-on lab

Understand token IDs and metadata.

Developer Intermediate
4/6 — ERC-721 NFT Implementation — Hands-on lab

Build for real users Start from audited libraries, lock down roles, and exercise every path in tests. Don’t guess that metadata renders—verify in a real wallet/marketplace. Document exactly who can mint, burn, pause, or change URIs.

Privacy is necessary for an open society in the electronic age.
— Whitfield Diffie

Plan the lifecycle Decide if you ever change baseURI. If yes, require a role and emit an event; if no, add a “freeze” toggle. Treat soulbound as an explicit rule: enforce non-transfer in code and explain it.

Think about support Provide a read function that returns the current baseURI and royalty info so wallets can self-serve. Keep your AccessControl roles obvious: DEFAULT_ADMIN_ROLE should not be the same hot wallet you mint from.

contract KudosNFT is ERC721URIStorage, AccessControl, ERC2981 {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    uint256 public immutable maxSupply;
    string private _base;
    uint256 private _minted;

    constructor(string memory name_, string memory symbol_, string memory base_, uint96 feeBps, address feeReceiver, uint256 cap)
        ERC721(name_, symbol_) {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(MINTER_ROLE, msg.sender);
        _base = base_;
        maxSupply = cap;
        _setDefaultRoyalty(feeReceiver, feeBps);
    }

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

    function setBaseURI(string memory newBase) external onlyRole(DEFAULT_ADMIN_ROLE) { _base = newBase; }

    function safeMint(address to, uint256 tokenId) external onlyRole(MINTER_ROLE) {
        require(_minted < maxSupply, "sold out");
        _minted++;
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, string.concat(Strings.toString(tokenId), ".json"));
    }

    function supportsInterface(bytes4 id) public view override(ERC721URIStorage, AccessControl, ERC2981) returns (bool) {
        return super.supportsInterface(id);
    }
}
Royalty payout
payout = salePrice × feeBps ÷ 10,000
EIP-2981 returns (receiver, feeAmount). Fee is advisory; marketplaces decide whether to enforce.
Remaining supply
remaining = maxSupply − totalMinted
Block mints when remaining ≤ 0; emit an event when the collection sells out.
Steps
  1. Start from OpenZeppelin ERC721;:Add name/symbol; wire ERC721URIStorage if you need per-token URIs.
  2. Implement safe mint with:AccessControl (MINTER_ROLE), supply cap, and a pause switch; emit Transfer on mint/burn.
  3. Override tokenURI to join:BaseURI + tokenId and allow a trusted role to update baseURI (or freeze it). Add a soulbound guard if needed.
  4. Add EIP-2981 royaltyInfo with:A default receiver + fee; test marketplace reads; document that royalties are opt-in.
  5. Write tests for mint,:Transfer, approvals, receiver checks, pausing, supply caps, and metadata changes; include fuzz cases for approvals.
  6. Deploy to a testnet,:Load tokenURI in an explorer/marketplace preview, and confirm images/descriptions render.
← Previous section
Next section →