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 在转账前会检查目标地址:
- 如果是 EOA(外部账户),直接转
- 如果是合约,调用
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 只针对单个 token | setApprovalForAll 授权所有 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 ERC721 | Gas 优化版实现 |
| ERC721A | Azuki 的批量铸造优化方案 |
| NFT Metadata Standard | OpenSea metadata 规范 |