ERC721.sol源码解读ERC721是ETH上的一种非同质化代币(NFT)标准,定义了一种唯一、不可分割、不可互换的代币类型。每一个ERC721代币都有唯一的tokenId和所有者,可以代表数字资产或现实世界中的物品,比如:数字艺术游戏道具虚拟地产门票......
ERC721 是 ETH上的一种非同质化代币(NFT)标准,定义了一种唯一、不可分割、不可互换
的代币类型。每一个 ERC721 代币都有唯一的 tokenId
和所有者,可以代表数字资产或现实世界中的物品,比如:
ERC721(基于v5 的新架构) 合约定义了如下核心函数:
function balanceOf(address owner) external view returns (uint256);
function ownerOf(uint256 tokenId) external view returns (address);
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 getApproved(uint256 tokenId) external view returns (address);
function setApprovalForAll(address operator, bool _approved) external;
function isApprovedForAll(address owner, address operator) external view returns (bool);
以及两个关键事件:
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.1.0) (token/ERC721/ERC721.sol)
pragma solidity ^0.8.20;
SPDX-License-Identifier
: 说明该文件使用的是 MIT 开源协议;pragma solidity ^0.8.20;
: 表示该合约编译器版本兼容 0.8.20 及以上(但不包括 0.9.0)。import {IERC721} from "./IERC721.sol";
import {IERC721Metadata} from "./extensions/IERC721Metadata.sol";
import {ERC721Utils} from "./utils/ERC721Utils.sol";
import {Context} from "../../utils/Context.sol";
import {Strings} from "../../utils/Strings.sol";
import {IERC165, ERC165} from "../../utils/introspection/ERC165.sol";
import {IERC721Errors} from "../../interfaces/draft-IERC6093.sol";
IERC721
: ERC721 标准接口定义(转账、授权、ownerOf 等);IERC721Metadata
: 扩展接口,提供 name()
、symbol()
和 tokenURI()
;ERC721Utils
: 提供工具函数,比如检查 onERC721Received
等;Context
: 获取调用上下文,如 _msgSender()
,支持 meta-transactions;Strings
: 提供 uint256 -> string
等辅助函数;IERC165
/ ERC165
: 用于实现接口识别(interface detection);IERC721Errors
: 草案中的错误定义接口(draft-EIP-6093),用于更明确的 revert 错误类型。abstract contract ERC721 is Context, ERC165, IERC721, IERC721Metadata, IERC721Errors
abstract contract
: 抽象合约,不能直接部署,需要继承后实现完整逻辑;
继承链:
Context
: 提供 _msgSender()
等;ERC165
: 实现接口查询机制;IERC721
: 实现核心 NFT 功能;IERC721Metadata
: 提供可读性更好的名称、符号和 URI;IERC721Errors
: 提供 revert 报错细节(EIP-6093);string private _name;
string private _symbol;
mapping(uint256 tokenId => address) private _owners;
mapping(address owner => uint256) private _balances;
mapping(uint256 tokenId => address) private _tokenApprovals;
mapping(address owner => mapping(address operator => bool)) private _operatorApprovals;
ERC721 的核心数据结构:
变量名 | 作用 |
---|---|
_name , _symbol |
token 集合的名称与符号(如 “CryptoKitties”, “CK”) |
_owners |
tokenId => owner ,表示每个 token 的所有者 |
_balances |
owner => token 数量 ,便于快速查询持有量 |
_tokenApprovals |
tokenId => address ,某个具体 token 的授权账户 |
_operatorApprovals |
owner => operator => bool ,是否全权授权给 operator(可以是多个operator) |
💡 注:这些变量基本遵循 EIP-721 的推荐数据结构设计
constructor(string memory name_, string memory symbol_) {
_name = name_;
_symbol = symbol_;
}
ERC721("MyNFT", "MNFT")
调用父构造函数function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
return
interfaceId == type(IERC721).interfaceId ||
interfaceId == type(IERC721Metadata).interfaceId ||
super.supportsInterface(interfaceId);
}
supportsInterface
是 ERC165 标准的一部分,用于检查合约是否实现某个接口。type(IERC721).interfaceId
获取接口的 selector。IERC721
IERC721Metadata
super.supportsInterface
来继续递归检查。✅ 用途:前端或其他合约可调用此函数,判断一个地址是否符合某个标准。
balanceOf
function balanceOf(address owner) public view virtual returns (uint256) {
if (owner == address(0)) {
revert ERC721InvalidOwner(address(0));
}
return _balances[owner];
}
owner
不能是零地址;_balances[owner]
返回其持有的 token 数量。💡
_balances
是一个映射,记录了每个地址拥有的 NFT 数量。
ownerOf
function ownerOf(uint256 tokenId) public view virtual returns (address) {
return _requireOwned(tokenId);
}
// _requireOwned(内部方法)
function _requireOwned(uint256 tokenId) internal view returns (address) {
address owner = _ownerOf(tokenId);
if (owner == address(0)) {
revert ERC721NonexistentToken(tokenId);
}
return owner;
}
// _ownerOf(内部方法)
function _ownerOf(uint256 tokenId) internal view virtual returns (address) {
return _owners[tokenId];
}
_ownerOf(tokenId)
获取当前所有者;☑️ 核心数据
_owners
提供 tokenId 对应的所有者。
name
和 symbol
function name() public view virtual returns (string memory) {
return _name;
}
function symbol() public view virtual returns (string memory) {
return _symbol;
}
IERC721Metadata
接口,通常用于 UI 显示 NFT 系列的名称和代号。tokenURI
function tokenURI(uint256 tokenId) public view virtual returns (string memory) {
if (_ownerOf(tokenId) == address(0)) {
revert ERC721NonexistentToken(tokenId);
}
return _baseURI();
}
// _baseURI 内部函数
function _baseURI() internal view virtual returns (string memory) {
return "";
}
_baseURI()
。🧠 实际应用中,一般会 override
_baseURI
或tokenURI
来返回形如"https://api.example.com/metadata/{id}"
的地址。
approve
不能同时授权
多个地址对一个 tokenId 使用 approve()
approve()
被授权操作某个 tokenId
approve(to, tokenId)
都会覆盖之前的授权地址。approve(address(0), tokenId)
,就是取消授权。 function approve(address to, uint256 tokenId) public virtual {
// to:授权账号;
// _mesgSender(): 当前合约的调用账号
_approve(to, tokenId, _msgSender());
}
function _approve(address to, uint256 tokenId, address auth) internal {
_approve(to, tokenId, auth, true);
}
function _approve(address to, uint256 tokenId, address auth, bool emitEvent) internal virtual {
// 是否需要 执行权限检查或发事件
// 如果 emitEvent == true:说明调用方希望发出 `Approval` 事件
// 如果 `auth != address(0)`:表示这次调用涉及“授权者”身份,必须要做权限校验
if (emitEvent || auth != address(0)) {
// 第一步安全检查:获取该 tokenId 的当前所有者,若不存在则 revert
address owner = _requireOwned(tokenId);
// 第二步安全检查:调用者(auth)是否合法
// 1. auth != address(0):如果 auth 是零地址,就不需要校验(常出现在清除授权场景)。
// 2. owner != auth:auth 不是该 token 的 owner。
// 3. !isApprovedForAll(owner, auth):auth 也不是 owner 授权的全局 operator。
// 三个条件同时满足,说明这个 `auth` 既不是 owner,也不是全局授权者,auth就不该拥有给别人授权的权力,直接 `revert ERC721InvalidApprover(auth)
// 防止“无权的人”调用 approve。
if (auth != address(0) && owner != auth && !isApprovedForAll(owner, auth)) {
revert ERC721InvalidApprover(auth);
}
// 事件触发(可选):内部 silent 操作时可能不发(比如 `transferFrom` 里清空授权)
if (emitEvent) {
emit Approval(owner, to, tokenId);
}
}
// 更新授权关系
// `to` 可以是新的授权地址,也可以是 `address(0)` 表示清除授权
_tokenApprovals[tokenId] = to;
}
🔍 解读:
_approve
函数完成授权;_tokenApprovals
是一个 mapping(uint256 => address)
,记录每个 tokenId
被授权的地址;Approval
事件(可选),前端或链上服务可监听。📌
_msgSender()
是Context
合约里的,表示当前调用合约的外部账户或合约地址
在 OpenZeppelin v5 架构中,很多底层函数像 _approve()
、_update()
都增加了 auth
参数,不直接使用 msg.sender
,而是显式传入这个“调用发起者”,主要目的是:
_approve()
,可以不用每次都重新计算权限,直接把调用者传进来做统一校验。auth == address(0)
表示“跳过权限验证”,假设调用是受信任的(比如由合约自身发起)
_approve(address(0), tokenId, address(0), false); // 清除授权,无需验证
这个例子常出现在 transfer、burn 等逻辑里。当 NFT 被转移或销毁时,需要清除授权关系,但这个动作是由合约自动发起的,不应该限制它只能由 owner/operator 才能做。
所以:
auth == address(0)
:说明这是合约内部自己发起的授权清除逻辑,不校验权限。auth == address(0)
,你应该非常确定当前代码逻辑已经是“安全场景”。setApprovalForAll
approve()
的单 token 授权function setApprovalForAll(address operator, bool approved) public virtual {
if (operator == _msgSender()) { // 不能授权给自己:调用者 == 授权者
revert ERC721InvalidOperator(_msgSender());
}
// 更新映射:调用者拥有的所有token是否授权给operator
_operatorApprovals[_msgSender()][operator] = approved;
// 触发ApprovalForAll 事件
emit ApprovalForAll(_msgSender(), operator, approved);
}
// 结构是:`owner => (operator => approved)`
mapping(address => mapping(address => bool)) private _operatorApprovals;
🔍 解读:
_operatorApprovals
里;ApprovalForAll
事件isApprovedForAll
function isApprovedForAll(address owner, address operator) public view virtual returns (bool) {
return _operatorApprovals[owner][operator];
}
getApproved
// 查看某个 token 是否已经单独授权给某个地址
function getApproved(uint256 tokenId) public view virtual returns (address) {
_requireOwned(tokenId); // 作用:检验tokenId的所有者是否是非零地址,没有使用函数的返回值
return _getApproved(tokenId);
}
function _getApproved(uint256 tokenId) internal view virtual returns (address) {
return _tokenApprovals[tokenId];
}
// 等价效果
function getApproved(uint256 tokenId) public view virtual returns (address) {
if (_ownerOf(tokenId) == address(0)) {
revert ERC721NonexistentToken(tokenId);
}
return _tokenApprovals[tokenId];
}
类型 | 地址 | 权限 | 能转让 NFT? | 能取消授权? |
---|---|---|---|---|
👑 Owner | 实际拥有者 _ownerOf(tokenId) |
完全控制权 | ✅ | ✅ |
🛂 Approved | approve(to, tokenId) 设置的地址 |
可转指定 NFT | ✅(仅该 token) | ❌(Owner 才能取消) |
🧰 Operator | setApprovalForAll(operator, true) 设置的地址 |
可转 owner 所有 NFT | ✅(任意 token) | ❌(Owner 才能取消) |
┌────────────────────┐
│ Owner │
│ │
│ - 完全控制 │
│ - 可授权别人 │
└────────┬───────────┘
│
▼
┌──────────────┐ ┌────────────────────┐
│ approve() │ 单 token 授权 → 一次一个 │
│ tokenId 1 → Bob ─────────> Bob 能操作 tokenId 1
└──────────────┘
┌──────────────┐ ┌────────────────────┐
│ setApproval │ 所有 token 授权 → 多个 operator │
│ operator = Eve ────────> Eve 可操作 Owner 所有 NFT
└──────────────┘
问:授权后的 NFT 真的安全吗?会不会被授权者随便转走? 答:一旦你授权了某个地址(单个 token 或全部 token),这个地址就真的可以把你的 NFT 转走
场景 | 安全建议 |
---|---|
✅ 没有要转移的需求 | 不要随便调用 approve() 或 setApprovalForAll() |
✅ 和 DApp 互动时 | 每次授权后,及时用 approve(address(0), tokenId) 取消授权 |
✅ 对整账户授权 | 在不需要时,及时用 setApprovalForAll(operator, false) 撤销 |
✅ 想知道 NFT 是否授权 | 查看 Etherscan 上的 token 授权记录(例如 OpenSea、Blur 等) |
✅ 自动化 | 使用 Wallet Guard 等工具拦截潜在风险授权 |
approve
给了钓鱼地址。transferFrom()
转走你的 NFT——而这是“合法操作”,你无法追回授权即赋权,慎之又慎!\ 只要你
approve()
或setApprovalForAll()
给了别人,他们就能随时合法转走你的 NFT,而这并不是 bug,而是设计如此。
transferFrom
function transferFrom(address from, address to, uint256 tokenId) public virtual {
if (to == address(0)) {
revert ERC721InvalidReceiver(address(0));
}
address previousOwner = _update(to, tokenId, _msgSender());
if (previousOwner != from) {
revert ERC721IncorrectOwner(from, tokenId, previousOwner);
}
}
// to: 目标地址(可以是 mint 的收件人、transfer 的接收方、或 burn 时是 address(0))
// tokenId: NFT 的编号
// auth: 发起请求者(如 _msgSender(),用于权限校验。为 `address(0)` 时跳过授权检查
// 返回值是 from,即之前的拥有者地址
function _update(address to, uint256 tokenId, address auth) internal virtual returns (address) {
// 获取当前 token 的持有者:读取 _owners[tokenId]
address from = _ownerOf(tokenId);
// 如果传入了授权者(auth)地址,检查是否被授权操作这个 token
if (auth != address(0)) {
_checkAuthorized(from, auth, tokenId);
}
// 如果这个 token 当前已经存在(即非 mint 情况):
// 1. 清除授权:调用 `_approve()` 清除 `tokenId` 的批准地址
// 2. 未触发 Approval 事件(传了 `emit=false`),更节省 gas
// 3. 余额减少(unchecked 避免溢出检查)
if (from != address(0)) {
_approve(address(0), tokenId, address(0), false); // 清除授权
unchecked {
_balances[from] -= 1;
}
}
// 如果不是 burn 行为,即:目标地址不是零地址
// 目标地址加上余额
if (to != address(0)) {
unchecked {
_balances[to] += 1;
}
}
// 更新 `_owners` 映射,把 tokenId 的所有权转到 `to`
// burn 时 `to == address(0)`,也就等于清除所有权
_owners[tokenId] = to;
// 统一生命周期事件,不管是 mint、transfer、burn,都发 `Transfer` 事件
emit Transfer(from, to, tokenId);
// 返回之前的 owner,用于上层函数继续做一致性检查
return from;
}
// _checkAuthorized() 会确保:
// auth == from(本人)
// 或 auth == getApproved(tokenId)(已被授权地址)
// 或 isApprovedForAll(from, auth)(operator)
// ⬅️ 这是所有授权流程的统一验证口。
function _checkAuthorized(address owner, address spender, uint256 tokenId) internal view virtual {
if (!_isAuthorized(owner, spender, tokenId)) { // 核心验证逻辑
if (owner == address(0)) {
revert ERC721NonexistentToken(tokenId);
} else {
revert ERC721InsufficientApproval(spender, tokenId);
}
}
}
function _isAuthorized(address owner, address spender, uint256 tokenId) internal view virtual returns (bool) {
// spender 是所有者 或 全局授权者 或 单个token授权者
return spender != address(0) &&
(owner == spender || isApprovedForAll(owner, spender) || _getApproved(tokenId) == spender);
}
🔍 解读:
_update()
更新 owner:【_update()
是 v5 的新内部逻辑方法】
_isAuthorized
验证:是否为 owner、被授权者或 operator_owners[tokenId]
映射_update()
虽完成了转账逻辑,但为了防止误调用或攻击行为(比如参数伪造),再进行一次检查:from
参数必须等于真正的所有者。from
参数(可能是攻击行为)🧠 补充理解
_update()
: OpenZeppelin v5 将许多“共用的 NFT 转移逻辑”抽象成_update()
方法,在 mint/burn/transfer 时都可以复用。其作用包括:
- 检查 token 是否存在;
- 验证调用者是否被授权;
- 更新 token 的 owner;
- 清除旧授权;
- 发出 Transfer 事件;
- 调用前后转移钩子(可重写
_beforeTokenTransfer
/_afterTokenTransfer
);
_isAuthorized
function _isAuthorized(address owner, address spender, uint256 tokenId) internal view virtual returns (bool) {
return (spender == owner ||
isApprovedForAll(owner, spender) ||
getApproved(tokenId) == spender);
}
safeTransferFrom
相较于普通的 transferFrom
,它多了一个重要的功能 —— 确保接收方(to)是一个可以处理 ERC721 的合约,防止 NFT 被转到不能处理它的合约地址后“卡住”
function safeTransferFrom(address from, address to, uint256 tokenId) public virtual {
safeTransferFrom(from, to, tokenId, "");
}
function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual {
transferFrom(from, to, tokenId);
ERC721Utils.checkOnERC721Received(_msgSender(), from, to, tokenId, data);
}
// utils/ERC721Utils.sol
function checkOnERC721Received(
address operator,
address from,
address to,
uint256 tokenId,
bytes memory data
) internal {
// 判断 `to` 是否是合约地址:`EOA` 的 `code.length` 恒为 0;合约地址则大于 0
if (to.code.length > 0) { // 是合约地址
// `try/catch` 用于安全调用目标合约(to)的 onERC721Received() 方法
// 若合约未实现该接口,将进入 `catch` 处理。
// 返回值 `retval` 应该等于 `IERC721Receiver.onERC721Received.selector` 【函数选择器】
try IERC721Receiver(to).onERC721Received(operator, from, tokenId, data) returns (bytes4 retval) {
// 合约(to)实现了接口但返回了错误的 selector,也会视为非法接收者
if (retval != IERC721Receiver.onERC721Received.selector) {
// Token rejected
revert IERC721Errors.ERC721InvalidReceiver(to);
}
} catch (bytes memory reason) {
// reason 为空,则说明目标合约根本没实现该接口,直接 revert
if (reason.length == 0) {
revert IERC721Errors.ERC721InvalidReceiver(to);
} else {
// 合约有自定义错误或 revert 消息),则原样抛出错误信息,方便开发者调试
assembly ("memory-safe") {
revert(add(32, reason), mload(reason))
}
}
}
}
}
🔍 解读:
transferFrom(from, to, tokenId);
_update()
更新内部状态(_balances、_owners、清除授权等)ERC721Utils.checkOnERC721Received(...)
:关键的安全性步骤
to
是一个合约地址,它必须实现 IERC721Receiver.onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data)
接口。revert
,防止 NFT 被锁死在不支持 ERC721 的合约里。_mint
函数:创建 NFT将一个新 NFT mint 给指定地址。必须保证:
to
地址合法。Transfer(address(0), to, tokenId)
事件function _mint(address to, uint256 tokenId) internal {
if (to == address(0)) {
revert ERC721InvalidReceiver(address(0));
}
address previousOwner = _update(to, tokenId, address(0));
if (previousOwner != address(0)) {
revert ERC721InvalidSender(address(0));
}
}
🔍 解读:
tokenId
的所有权设为 to
;_balances[to] += 1
;_owners[tokenId] = to
;Transfer(from, to, tokenId)
事件,此时 from == address(0)
;previousOwner
—— 如果该 tokenId
已经存在,则返回已有 owner
其中 auth = address(0)
,表示无授权检查(因为 mint 是创建,不涉及授权)previousOwner != address(0)
,说明该 tokenId 已经存在
ERC721InvalidSender(address(0))
:意思是不能从零地址向一个已经存在的 token 发起 mint_burn
函数:销毁 NFTaddress(0)
);function _burn(uint256 tokenId) internal {
address previousOwner = _update(address(0), tokenId, address(0));
if (previousOwner == address(0)) {
revert ERC721NonexistentToken(tokenId);
}
}
🔍 解读:
to = address(0)
,意味着要销毁 token。auth = address(0)
,代表不校验授权(因为这个函数是内部调用,外层一般已做检查_update
会自动做这些事:
a. 获取 token 的当前所有者
b. 清除该 token 的授权
c. 扣减当前所有者余额
d. 清除 _owners[tokenId]
映射;
e. 触发 Transfer(previousOwner, address(0), tokenId)
事件🚨 如果该 token 从未存在,那么 _ownerOf(tokenId)
返回 0,说明当前 from == address(0)
,也就是说,尝试销毁一个不存在的 token
_update()
会返回 token 原本的 owner(即 from)ERC721NonexistentToken(tokenId)
IERC721.sol 源码
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.1.0) (token/ERC721/IERC721.sol)
pragma solidity ^0.8.20;
import {IERC165} from "../../utils/introspection/IERC165.sol";
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 balance);
function ownerOf(uint256 tokenId) external view returns (address owner);
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata 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 operator);
function isApprovedForAll(address owner, address operator) external view returns (bool);
}
pragma solidity ^0.8.20;
import {IERC721} from "../IERC721.sol";
interface IERC721Metadata is IERC721 {
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function tokenURI(uint256 tokenId) external view returns (string memory);
}
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!