返回 SC 笔记
SC Day 12

Solidity - ERC721标准 + 手写完整实现

### 1. EIP-721 规范概览

2026-04-12
第一阶段:基础构建
solidityerc721nfttoken-standardsmart-contract

日期: 2026-04-12 方向: Solidity 阶段: 第一阶段:基础构建 标签: #solidity #erc721 #nft #token-standard #smart-contract


今日目标

类型内容
学习深入理解 EIP-721 规范,掌握每个函数的设计意图和安全考量
实操从零手写完整 ERC721 合约,包含 metadata、minting、safe transfer 钩子
产出完整可部署的 ERC721 合约 + IERC721Receiver 实现 + 安全分析

核心概念

1. EIP-721 规范概览

ERC721 是以太坊上非同质化代币 (NFT) 的标准接口,由 William Entriken 等人于 2018 年提出。与 ERC20 不同,每个 ERC721 代币都有唯一的 tokenId,不可互换。

ERC721 必须实现的接口

interface IERC721 {
    // ====== 事件 ======
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);

    // ====== 查询函数 ======
    function balanceOf(address owner) external view returns (uint256 balance);
    function ownerOf(uint256 tokenId) external view returns (address owner);

    // ====== 转账函数 ======
    function transferFrom(address from, address to, uint256 tokenId) external;
    function safeTransferFrom(address from, address to, uint256 tokenId) external;
    function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;

    // ====== 授权函数 ======
    function approve(address to, uint256 tokenId) external;
    function getApproved(uint256 tokenId) external view returns (address operator);
    function setApprovalForAll(address operator, bool approved) external;
    function isApprovedForAll(address owner, address operator) external view returns (bool);
}

ERC721Metadata 扩展接口

interface IERC721Metadata {
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function tokenURI(uint256 tokenId) external view returns (string memory);
}

IERC721Receiver:安全转账的守护者

interface IERC721Receiver {
    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4);
}

2. 核心设计理念

为什么需要 safeTransferFrom?

普通的 transferFrom 可以将 NFT 转给任意地址,包括不支持 ERC721 的合约地址。一旦转入,NFT 就永远锁死在那个合约里,无法取回。

safeTransferFrom 在转账前会检查目标地址:

  1. 如果是 EOA(外部账户),直接转
  2. 如果是合约,调用 onERC721Received(),检查返回值是否为 bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))

approve vs setApprovalForAll

函数作用域使用场景
approve单个 tokenId用户在市场上挂一个 NFT 出售
setApprovalForAll所有 token用户授权市场管理其所有 NFT

3. _mint vs _safeMint 的区别

_mint(to, tokenId):
  ✅ 仅更新状态(ownership + balance)
  ✅ 触发 Transfer 事件
  ❌ 不检查接收方是否能处理 ERC721

_safeMint(to, tokenId):
  ✅ 调用 _mint
  ✅ 如果 to 是合约,调用 onERC721Received 检查
  ⚠️ 有重入风险(因为调用了外部合约)

代码实战:完整 ERC721 实现

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// ========== 接口定义 ==========

interface IERC165 {
    function supportsInterface(bytes4 interfaceId) external view returns (bool);
}

interface IERC721 is IERC165 {
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);

    function balanceOf(address owner) external view returns (uint256);
    function ownerOf(uint256 tokenId) external view returns (address);
    function transferFrom(address from, address to, uint256 tokenId) external;
    function safeTransferFrom(address from, address to, uint256 tokenId) external;
    function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
    function approve(address to, uint256 tokenId) external;
    function getApproved(uint256 tokenId) external view returns (address);
    function setApprovalForAll(address operator, bool approved) external;
    function isApprovedForAll(address owner, address operator) external view returns (bool);
}

interface IERC721Metadata {
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function tokenURI(uint256 tokenId) external view returns (string memory);
}

interface IERC721Receiver {
    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4);
}

// ========== 完整 ERC721 实现 ==========

contract MyNFT is IERC721, IERC721Metadata {
    // ====== 状态变量 ======
    string private _name;
    string private _symbol;
    string private _baseTokenURI;

    // tokenId => owner
    mapping(uint256 => address) private _owners;
    // owner => token count
    mapping(address => uint256) private _balances;
    // tokenId => approved address
    mapping(uint256 => address) private _tokenApprovals;
    // owner => operator => approved
    mapping(address => mapping(address => bool)) private _operatorApprovals;

    // Minting 相关
    uint256 private _nextTokenId;
    uint256 public constant MAX_SUPPLY = 10000;
    uint256 public constant MINT_PRICE = 0.01 ether;
    address public immutable owner;

    // 自定义 token URI(可选)
    mapping(uint256 => string) private _tokenURIs;

    // ====== 构造函数 ======
    constructor(
        string memory name_,
        string memory symbol_,
        string memory baseURI_
    ) {
        _name = name_;
        _symbol = symbol_;
        _baseTokenURI = baseURI_;
        owner = msg.sender;
        _nextTokenId = 1; // Token ID 从 1 开始
    }

    // ====== ERC165: 接口支持检查 ======
    function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
        return
            interfaceId == type(IERC165).interfaceId ||     // 0x01ffc9a7
            interfaceId == type(IERC721).interfaceId ||     // 0x80ac58cd
            interfaceId == type(IERC721Metadata).interfaceId; // 0x5b5e139f
    }

    // ====== Metadata 函数 ======
    function name() external view returns (string memory) {
        return _name;
    }

    function symbol() external view returns (string memory) {
        return _symbol;
    }

    function tokenURI(uint256 tokenId) external view returns (string memory) {
        require(_exists(tokenId), "ERC721: token does not exist");

        // 优先使用自定义 URI
        string memory _tokenURI = _tokenURIs[tokenId];
        if (bytes(_tokenURI).length > 0) {
            return _tokenURI;
        }

        // 否则拼接 baseURI + tokenId
        return string(abi.encodePacked(_baseTokenURI, _toString(tokenId), ".json"));
    }

    // ====== 查询函数 ======
    function balanceOf(address owner_) external view returns (uint256) {
        require(owner_ != address(0), "ERC721: zero address");
        return _balances[owner_];
    }

    function ownerOf(uint256 tokenId) public view returns (address) {
        address tokenOwner = _owners[tokenId];
        require(tokenOwner != address(0), "ERC721: nonexistent token");
        return tokenOwner;
    }

    // ====== 授权函数 ======
    function approve(address to, uint256 tokenId) external {
        address tokenOwner = ownerOf(tokenId);
        require(to != tokenOwner, "ERC721: approval to current owner");
        require(
            msg.sender == tokenOwner || isApprovedForAll(tokenOwner, msg.sender),
            "ERC721: not owner or approved for all"
        );

        _tokenApprovals[tokenId] = to;
        emit Approval(tokenOwner, to, tokenId);
    }

    function getApproved(uint256 tokenId) public view returns (address) {
        require(_exists(tokenId), "ERC721: nonexistent token");
        return _tokenApprovals[tokenId];
    }

    function setApprovalForAll(address operator, bool approved) external {
        require(operator != msg.sender, "ERC721: approve to caller");
        _operatorApprovals[msg.sender][operator] = approved;
        emit ApprovalForAll(msg.sender, operator, approved);
    }

    function isApprovedForAll(address owner_, address operator) public view returns (bool) {
        return _operatorApprovals[owner_][operator];
    }

    // ====== 转账函数 ======
    function transferFrom(address from, address to, uint256 tokenId) public {
        require(_isApprovedOrOwner(msg.sender, tokenId), "ERC721: not authorized");
        _transfer(from, to, tokenId);
    }

    function safeTransferFrom(address from, address to, uint256 tokenId) external {
        safeTransferFrom(from, to, tokenId, "");
    }

    function safeTransferFrom(
        address from,
        address to,
        uint256 tokenId,
        bytes memory data
    ) public {
        require(_isApprovedOrOwner(msg.sender, tokenId), "ERC721: not authorized");
        _safeTransfer(from, to, tokenId, data);
    }

    // ====== Minting 函数 ======

    /// @notice 公开铸造函数(任何人可付费铸造)
    function mint() external payable returns (uint256) {
        require(msg.value >= MINT_PRICE, "Insufficient payment");
        require(_nextTokenId <= MAX_SUPPLY, "Max supply reached");

        uint256 tokenId = _nextTokenId;
        _nextTokenId++;

        _safeMint(msg.sender, tokenId, "");
        return tokenId;
    }

    /// @notice 批量铸造
    function mintBatch(uint256 quantity) external payable returns (uint256[] memory) {
        require(msg.value >= MINT_PRICE * quantity, "Insufficient payment");
        require(_nextTokenId + quantity - 1 <= MAX_SUPPLY, "Exceeds max supply");

        uint256[] memory tokenIds = new uint256[](quantity);
        for (uint256 i = 0; i < quantity; i++) {
            tokenIds[i] = _nextTokenId;
            _safeMint(msg.sender, _nextTokenId, "");
            _nextTokenId++;
        }
        return tokenIds;
    }

    /// @notice Owner 可以免费铸造(预留/空投用)
    function ownerMint(address to, uint256 quantity) external {
        require(msg.sender == owner, "Not owner");
        require(_nextTokenId + quantity - 1 <= MAX_SUPPLY, "Exceeds max supply");

        for (uint256 i = 0; i < quantity; i++) {
            _safeMint(to, _nextTokenId, "");
            _nextTokenId++;
        }
    }

    // ====== 提现 ======
    function withdraw() external {
        require(msg.sender == owner, "Not owner");
        (bool success, ) = owner.call{value: address(this).balance}("");
        require(success, "Withdraw failed");
    }

    // ====== 内部函数 ======

    function _exists(uint256 tokenId) internal view returns (bool) {
        return _owners[tokenId] != address(0);
    }

    function _isApprovedOrOwner(address spender, uint256 tokenId) internal view returns (bool) {
        address tokenOwner = ownerOf(tokenId);
        return (
            spender == tokenOwner ||
            getApproved(tokenId) == spender ||
            isApprovedForAll(tokenOwner, spender)
        );
    }

    function _transfer(address from, address to, uint256 tokenId) internal {
        require(ownerOf(tokenId) == from, "ERC721: wrong owner");
        require(to != address(0), "ERC721: transfer to zero address");

        // 清除之前的授权
        delete _tokenApprovals[tokenId];

        // 更新余额
        unchecked {
            _balances[from] -= 1;
            _balances[to] += 1;
        }

        // 更新所有权
        _owners[tokenId] = to;

        emit Transfer(from, to, tokenId);
    }

    function _safeTransfer(
        address from,
        address to,
        uint256 tokenId,
        bytes memory data
    ) internal {
        _transfer(from, to, tokenId);
        _checkOnERC721Received(from, to, tokenId, data);
    }

    function _mint(address to, uint256 tokenId) internal {
        require(to != address(0), "ERC721: mint to zero address");
        require(!_exists(tokenId), "ERC721: token already minted");

        _balances[to] += 1;
        _owners[tokenId] = to;

        emit Transfer(address(0), to, tokenId);
    }

    function _safeMint(address to, uint256 tokenId, bytes memory data) internal {
        _mint(to, tokenId);
        _checkOnERC721Received(address(0), to, tokenId, data);
    }

    /// @dev 检查目标合约是否实现了 IERC721Receiver
    function _checkOnERC721Received(
        address from,
        address to,
        uint256 tokenId,
        bytes memory data
    ) private {
        // 如果目标是 EOA,无需检查
        if (to.code.length == 0) {
            return;
        }

        // 调用目标合约的 onERC721Received
        try IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, data) returns (
            bytes4 retval
        ) {
            require(
                retval == IERC721Receiver.onERC721Received.selector,
                "ERC721: unsafe recipient"
            );
        } catch (bytes memory reason) {
            if (reason.length == 0) {
                revert("ERC721: non-ERC721Receiver");
            } else {
                assembly {
                    revert(add(32, reason), mload(reason))
                }
            }
        }
    }

    /// @dev 将 uint256 转为字符串
    function _toString(uint256 value) internal pure returns (string memory) {
        if (value == 0) return "0";

        uint256 temp = value;
        uint256 digits;
        while (temp != 0) {
            digits++;
            temp /= 10;
        }

        bytes memory buffer = new bytes(digits);
        while (value != 0) {
            digits -= 1;
            buffer[digits] = bytes1(uint8(48 + uint256(value % 10)));
            value /= 10;
        }

        return string(buffer);
    }

    // ====== 查询辅助 ======
    function totalSupply() external view returns (uint256) {
        return _nextTokenId - 1;
    }
}

ERC721Receiver 实现示例

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title NFT 金库:安全接收和管理 NFT 的合约
contract NFTVault is IERC721Receiver {
    // NFT 合约地址 => tokenId => 存入者
    mapping(address => mapping(uint256 => address)) public depositors;

    /// @notice 实现 IERC721Receiver,允许合约安全接收 NFT
    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external override returns (bytes4) {
        // 记录存入者,方便后续取回
        depositors[msg.sender][tokenId] = from;

        // 返回正确的 selector,表示接受此 NFT
        return IERC721Receiver.onERC721Received.selector;
        // 等同于: return bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"));
    }

    /// @notice 取回自己存入的 NFT
    function withdraw(address nftContract, uint256 tokenId) external {
        require(
            depositors[nftContract][tokenId] == msg.sender,
            "Not the depositor"
        );

        delete depositors[nftContract][tokenId];

        // 将 NFT 转回给存入者
        IERC721(nftContract).safeTransferFrom(address(this), msg.sender, tokenId);
    }
}

关键要点总结

要点说明
tokenId 全局唯一每个 NFT 有唯一 ID,不可互换
safeTransferFrom 检查接收方防止 NFT 锁死在不支持的合约中
approve 只针对单个 tokensetApprovalForAll 授权所有 token
_safeMint 有重入风险调用外部合约的 onERC721Received
Transfer(address(0), to, id)铸造事件,from 为零地址
Transfer(from, address(0), id)销毁事件,to 为零地址
ERC165 接口检测其他合约可以查询是否支持 ERC721
tokenURI 返回 metadata通常指向 IPFS 或中心化 API 的 JSON

常见误区

误区 1:忘记在转账时清除 approve

// 错误:转账后旧的 approve 仍然有效
function _transfer(address from, address to, uint256 tokenId) internal {
    _owners[tokenId] = to;
    // 忘记 delete _tokenApprovals[tokenId];
    // 导致旧的 approved 地址仍能操作这个 NFT!
}

误区 2:用 _mint 代替 _safeMint 给合约地址铸造

// 危险:如果 to 是不支持 ERC721 的合约,NFT 将永远锁死
_mint(someContractAddress, tokenId);

// 安全:会检查合约是否实现 IERC721Receiver
_safeMint(someContractAddress, tokenId, "");

误区 3:忽略 _safeMint 的重入攻击

// 漏洞:在 _safeMint 内部调用 onERC721Received 时,
// 恶意合约可以回调 mint() 再次铸造
function mint() external payable {
    require(msg.value >= MINT_PRICE, "Insufficient");
    uint256 tokenId = _nextTokenId;
    _nextTokenId++;       // 先更新状态
    _safeMint(msg.sender, tokenId, "");  // 再调用外部合约(CEI 模式)
}

// 如果顺序反了(先 safeMint 再更新 nextTokenId),就可能被重入攻击

误区 4:tokenURI 返回错误格式

NFT 市场期望 tokenURI 返回的 JSON 格式:

{
    "name": "My NFT #1",
    "description": "A unique digital collectible",
    "image": "ipfs://QmXxx.../1.png",
    "attributes": [
        { "trait_type": "Background", "value": "Blue" },
        { "trait_type": "Rarity", "value": "Rare" }
    ]
}

面试关联

Q: ERC721 的 safeTransferFrom 和 transferFrom 有什么区别?什么时候用哪个?

30 秒回答safeTransferFrom 在转账后会检查目标地址是否能处理 ERC721(通过调用 onERC721Received),防止 NFT 锁死在不支持的合约中。transferFrom 不做这个检查,Gas 更低但有丢失风险。普通用户之间转账用 safe 版本,已知安全的合约交互可以用普通版本节省 Gas。

Q: _mint 和 _safeMint 有什么区别?有什么安全风险?

  • _mint 只更新状态,不调用外部合约,没有重入风险
  • _safeMint 会调用接收方的 onERC721Received,如果接收方是恶意合约,可能发生重入攻击
  • 解决方案:使用 CEI (Checks-Effects-Interactions) 模式,先更新状态再调用外部合约

Q: 为什么 NFT 市场需要 setApprovalForAll?

用户在 OpenSea/Blur 上架 NFT 时,需要授权市场合约操作自己的 NFT。如果每上架一个都要单独 approve,用户体验极差(每次都要签名交易)。setApprovalForAll 一次授权后,市场合约可以代为转移用户的任意 NFT,大幅提升 UX。


参考资源

资源说明
EIP-721官方标准文档,必读
OpenZeppelin ERC721业界标准实现
Solmate ERC721Gas 优化版实现
ERC721AAzuki 的批量铸造优化方案
NFT Metadata StandardOpenSea metadata 规范