有个朋友拿合约代码来找我,说他把游戏里的专属道具做成了代币,结果玩家发现同一件限量装备能被拆分出0.5个,还把“稀有度”直接拆没了。另一个新手问得更多:ETH到底算不算一个ERC20代币?这俩问题表面上是使用姿势不对,根子其实是一样的——很多人把Coin和Token混为一谈,也没想清楚ERC20和ERC721各自对应的账本逻辑。
这篇就把这块掰开揉碎:Coin和Token的区别、ERC20与ERC721的设计逻辑、Solidity 0.8.4之后的自定义错误机制,以及合约继承怎么把这些东西组织成一个能上线的项目。适合已经能写简单合约、但想真正读懂OpenZeppelin源码的开发者;纯新人的话,把这四个概念当成进入智能合约世界的四块垫脚石也完全够用。
1. Coin与Token:为什么ETH不是ERC20代币
1.1 协议记账与合约记账
Coin和Token在中文社区经常被混着叫,大家都说“代币”,但技术上它们是两层东西。
Coin是链的原生资产。拿以太坊来说,ETH的余额由协议层维护,存在账户状态里,由状态树统一管理。你转账ETH,本质是协议在执行一个“账户状态变更”操作,根本不需要经过某个合约的代码逻辑。所以Solidity里没有任何一个合约能直接修改另一个地址的原生余额,你只能通过转账动作让协议层去改。
Token则是部署在链上的合约里记录的数字。合约内部用一个mapping,记着“谁谁谁有多少个Token”,比如mapping(address => uint256) public balanceOf。你转Token,是调用一个合约函数,修改的是合约自己的存储空间。合约定多少总量、能不能增发、能不能销毁,完全由合约代码说了算。
理解这一层差异特别重要。为什么Coin能付gas、能参与协议层的质押?因为它是协议资产。为什么有些Token只能在特定应用里流通?因为它只是那个合约里的一条账本记录。
1.2 零地址和WETH:Coin进入Token生态的桥
很多交易协议在处理“ETH兑换某个ERC20代币”时,会用一个约定:用零地址address(0)代表ETH。为什么?因为ETH本身不是ERC20,没有balanceOf、transfer这些标准函数,无法塞进通用的代币路由逻辑里。
为了解决这个问题,就有了WETH(Wrapped Ether)。WETH9合约做的事情很直白:你调用deposit()并附带ETH,合约按照1:1给你铸造WETH;你调用withdraw(amount),合约销毁对应WETH并把ETH还给你。这样ETH就被“包装”成了标准ERC20,可以参与DEX的流动性池、借贷抵押等所有ERC20生态。
这就引出一个很常见的现实坑:有人直接把ETH转给某个合约地址,以为对方会自动收到一笔“代币余额”。实际上合约是否能接收ETH,取决于它有没有定义receive()或者fallback()回退函数。很多只处理ERC20的合约压根没写支付函数,ETH转过去交易直接失败;也有少数合约能收ETH,但没有提供任何提取入口,资金就变成了一笔永远沉睡在合约里的余额。
顺带说一句,ERC20代币直接转到不兼容的合约地址也会出现类似问题:Token进账了,但合约里没有逻辑去操作这笔余额,就成了死币。集成第三方协议时,要先确认对方是否有对应的“提现”或“退款”路径。
1.3 Coin与Token的关键对比
| 维度 | Coin(原生资产) | Token(合约代币) |
|---|---|---|
| 记账方 | 协议层账户状态 | 合约storage |
| 转账方式 | 协议转账 | 调用合约函数 |
| 存在形式 | 链本身的资产 | 合约里的账本记录 |
| 能否付gas | 能 | 通常不能 |
| 发行规则 | 由共识与协议规则决定 | 由合约代码决定 |
| 化名地址 | 零地址常代表ETH | 有实际合约地址 |
实际操作中,判断一个资产是Coin还是Token,最简单的方式就是看它有没有合约地址。没有合约地址、由协议层直接维护余额的是Coin;需要依赖合约逻辑流转的是Token。这个判断在做链上数据解析、钱包开发或者DEX路由集成时,几乎每天都会用到。
2. ERC20与ERC721:两套账本,两种所有权
2.1 ERC20的“余额+授权”模型
ERC20解决的是同质化资产的记账问题。同质化是什么意思?你手里的1个A代币和我手里的1个A代币完全等价,可以互相替换,可以拆分,可以加总。
最简ERC20的核心数据结构其实就这几样:
mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; uint256 public totalSupply; event Transfer(address indexed from, address indexed to, uint256 amount); event Approval(address indexed owner, address indexed spender, uint256 amount);balanceOf记录每个地址的余额,allowance记录“A授权B可以动用多少A的余额”,totalSupply是总量。标准接口里的approve和transferFrom就是围绕这两个mapping设计的。
为什么需要授权而不是每次都由本人转账?因为很多场景下,合约需要代替用户扣款。你去DEX用代币换另一个代币,链上执行链下订单,实际是由兑换合约调用transferFrom从你的余额里扣钱,再转给交易对手。如果只能本人转账,DEX合约就永远无法执行“用户委托交易”了。
这里有三个实际踩过的坑值得记住:
- 授权竞态问题。
approve把授权额度从5改成3,本质是两次独立交易,中间如果有人抢先调用transferFrom,你的额度变化可能不符合预期。惯例做法是先approve(spender, 0)再approve(spender, value)。 - 无限授权。很多DEX会让用户授权一个超大额度(比如
type(uint256).max),合约一旦被攻击,用户余额可能被一次性掏空。现在很多协议改用临时授权或额度递减,但老一代合约普遍是无限授权。 - 返回值不一致。标准ERC20要求
transfer和transferFrom返回bool,但USDT这类早期代币没有返回值,直接让很多新手合约遇到“交易回滚”。所以实际项目里最好用SafeERC20这类封装库,它会检查返回值。
2.2 ERC721的“归属+批准”模型
ERC721处理的是非同质化资产,每个tokenId代表一个独一无二的物品。它不记录“你有多少个同款”,而是记录“这个特定物品归谁”。
核心数据结构长这样:
mapping(uint256 => address) private _owners; mapping(address => uint256) private _balances; mapping(uint256 => address) private _tokenApprovals; mapping(address => mapping(address => bool)) private _operatorApprovals;_owners是核心:tokenId到持有人的映射,它决定了一项资产的归属。_balances存的是“某人持有多少个不同的NFT”,而不是NFT的数量可以拆分。_tokenApprovals允许持有人针对某个特定tokenId授权他人转移,_operatorApprovals则是一次性授权某个操作者管理持有人全部NFT。
ERC721里有一个关键安全点:safeTransferFrom。和ERC20的transfer不同,NFT转到一个合约地址之前,接收合约必须实现onERC721Received接口并返回正确的函数选择器(0x150b7a02),否则转账回滚。这是为了防止NFT被意外发送到一个没有处理能力的合约里,导致资产永久锁死。
用ERC20的思路写NFT最容易犯的错,就是把每个NFT当成一个可拆分的小数来处理。ERC721没有decimals概念,一个tokenId就是一件完整资产。你没法把一件NFT转0.5个出去,这恰恰是它的设计价值:资产不可拆,所有权分界清晰。
2.3 何时用ERC20,何时用ERC721
| 对比项 | ERC20 | ERC721 |
|---|---|---|
| 代币单位 | 可拆分、有decimals | 不可拆分、tokenId唯一 |
| 记账核心 | balanceOf + allowance | ownerOf + approvals |
| 转账语义 | 转移数量 | 转移某个具体资产 |
| 典型场景 | 积分、支付、治理、流动性 | 藏品、票据、身份、装备 |
| 存储开销 | 一个地址一行余额 | 每个资产多行状态 |
选型时我会先问自己:这东西能被拆分成0.5个还合理吗?合理用ERC20,不合理用ERC721。如果是一类资产、每个个体有共性但又有差异,比如游戏里同一种皮肤但有不同的编号,那么ERC721更合适;如果你的业务是积分、点数、治理投票权,ERC20是自然选择。还有介于两者之间的场景,比如半同质化的游戏道具批量生成,可以看ERC1155,但这篇文章不展开。
3. 自定义错误机制:从Error(string)到Panic再到自定义error
3.1 EVM里的错误长什么样
Solidity里的异常处理表面上就是require、revert、assert这几个关键字,但到了EVM层面,它们产生的数据是完全不同的。
require(false, "Insufficient balance")底层会触发一个Error(string)类型的revert数据,链上编码是:
- 函数选择器:
0x08c379a0 - 后面跟着ABI编码的字符串:偏移量、字符串长度、字符串内容,右填充到32字节倍数
所以哪怕是一条很短的错误消息,revert数据也会有上百字节。
assert(false)以及0.8.0之后的某些自动检查失败,会触发Panic(uint256)类型的数据,选择器是0x4e487b71,后面跟一个错误码。常用错误码有几个:0x01是assert条件不满足,0x11是算术溢出或下溢,0x12是除以0,0x32是数组下标越界。
Solidity 0.8.0之后,整数的溢出和下溢默认会回滚,这就是为什么你明明没有写assert,却在某些算术错误时看到Panic(0x11)。这是编译器自动插入的检查。
很多人以为require失败会扣掉所有gas,其实不是这样。revert发生时,EVM会把当前调用栈里剩余的gas返还给调用者,已经消耗掉的gas不会返还。也就是说,revert不会“吞掉全部gas”,真正影响成本的是你已经执行的运算,以及你需要打包到交易数据或返回给调用者的错误数据长度。
3.2 error关键字的正确打开方式
Solidity 0.8.4引入了自定义错误,语法很简洁:
error NotOwner(uint256 tokenId); error InsufficientBalance(uint256 available, uint256 required); function withdraw(uint256 amount) external view { uint256 balance = balanceOf[msg.sender]; if (balance < amount) { revert InsufficientBalance(balance, amount); } // 继续业务逻辑 }自定义错误没有字符串,它的revert数据只有“错误签名哈希的前4字节”加“按ABI编码的参数”。
拿NotOwner(uint256)来说,无参数的自定义错误只有4字节;带两个uint256参数的,也就是4+64=68字节。对比一下前面Error(string)动辄上百字节的载荷,区别很明显。
这里省gas的原理不是“错误触发的瞬间能返还更多gas”,而是:
- 错误数据更短,调用者和节点处理时消耗的gas更少;
- 错误签名作为编译期常量,不会像字符串那样被完整写到合约字节码里,部署合约更便宜;
- 高频路径上,比如一个资金池的存取款,每次失败都省几十到几百gas,积累起来是实打实的成本优化。
所以在0.8.4之后的合约里,我的习惯是:常规校验用require,需要给调用者清晰错误信息、希望链上监控更精准的地方,一律用自定义错误。assert只在“绝对不变量”的地方使用,比如assert(totalSupply == sumOfAllBalances),它更多是给审计工具和开发者看的不变量声明。
3.3 链上观察、解析与try/catch
自定义错误的一个实际问题是:它比require("字符串")更难在浏览器上直接读懂。你打开区块链浏览器看一个失败交易,可能只看到一堆十六进制数据,不显示具体错误内容。
好在我可以用Foundry或者ethers.js做解码。cast命令里:
cast sig "Error(string)" # 0x08c379a0 cast sig "Panic(uint256)" # 0x4e487b71 cast sig "NotOwner(uint256)"然后解析revert字节,第一步先看前4字节是不是某个已知错误的选择器。如果自定义错误带参数,还要按ABI把参数解出来。ethers.js里你可以引入合约的ABI,让工具自动还原成可读的错误文本。
在合约内部如果想捕获外部调用的错误,Solidity提供了try/catch。注意它的限制:只能捕获外部调用、address.call或合约实例调用触发的revert,不能捕获当前合约内部自己的错误。语法上支持多个分支:
try pool.swap(...) returns (uint256 amountOut) { // 成功路径 } catch Error(string memory reason) { // 捕获 require/revert("...") } catch (bytes memory reason) { // 捕获 Panic 或自定义错误 if (bytes4(reason) == MyError.selector) { // 处理自定义错误 } }这里有个容易被忽略的点:不要试图把敏感信息放进错误参数里。错误数据最终会留在链上,任何人通过交易日志和失败状态都能看到。把用户的完整私密数据或者别人的隐私字段写进error参数,等于公开展示。
4. 合约继承:把共享逻辑拆成基类,而不是复制粘贴
4.1 继承解决什么问题
合约继承和面向对象里的继承很像,本质是代码复用和分层抽象。如果你打开OpenZeppelin的合约库,会发现几乎不用“复制粘贴”这套土办法。用到权限就is Ownable,用到暂停机制就is Pausable,发一个代币就继承ERC20。
举一个最常用的例子:
contract MyToken is ERC20, Ownable { constructor(address initialOwner) ERC20("MyToken", "MTK") Ownable(initialOwner) {} }一行is ERC20,整个标准代币的函数和状态变量(totalSupply、balanceOf、approve、transferFrom等)全部复用。一行is Ownable,立刻有了owner、onlyOwner修饰符、transferOwnership函数。继承的核心价值就是:你不需要重新实现一个轮子,只需要站在基类的能力之上增加业务逻辑。
但继承也带来一个问题:状态变量和函数不是“复制”到子合约,而是被编译进同一个合约的存储布局里。基类的状态变量按照既定顺序排在前面,子类状态变量排在后面。这个顺序一旦在升级中被打乱,现有数据就会在存储槽上错位,读出来全是垃圾。
4.2 virtual、override、abstract、interface怎么配合
Solidity里一个函数默认情况下不允许被重写,必须显式声明virtual。子类重写父类的函数时必须用override标注。如果同时重写了多个基类的同名函数,要写成override(A, B)。
看一个典型的抽象层级:
abstract contract Base { function greet() public pure virtual returns (string memory) { return "base"; } } contract Child is Base { function greet() public pure override returns (string memory) { return "child"; } }abstract合约表示“这个合约不完整”,它可以有未实现的函数,因此不能直接部署,只能被子合约继承后补全。interface的约束更强:不能有任何状态变量,不能有构造函数,所有函数都只有签名没有实现,可以定义事件和自定义错误。
什么时候用interface,什么时候用abstract?我的经验是:对外面向协议的交互(比如IAMCALL的调用约定)用interface,因为接口天然适合定义调用边界;对内部复用、已经有部分实现的逻辑用abstract基类,比如Ownable、Pausable这类包含状态和方法的模块。OpenZeppelin很多模块其实是contract而不是interface,原因就是它需要持有自己的存储变量。
继承还有一个语法细节:基类构造函数带参数时,参数写在继承列表里或子类构造函数里。上面例子里Ownable(initialOwner)就是子类构造时向基类传参的写法。基类构造函数的执行顺序遵循线性化规则,最基类的构造函数会先执行,之后才是更派生的部分。
4.3 多重继承与组合的取舍
Solidity支持多重继承,用的时候要小心。不同基类如果有同名函数,子类必须显式重写并声明override(Base1, Base2)。调用super时,会按C3线性化顺序找到“下一个同名实现”。这里有两个真实教训:
第一,继承列表的顺序会影响存储布局。contract X is A, B和contract X is B, A,A、B的状态变量在存储槽里的相对顺序可能不同。如果你在做可升级合约,不同版本之间哪怕只是交换了继承顺序,也可能导致旧数据处理错位。升级时不要随意改继承结构。
第二,不要在基类构造函数里调用会被子类重写的函数。原因很简单:构造函数执行时,子类的状态变量还没初始化,动态分发不会走到子类重写版本,你会拿到一个“半初始化”状态。我在早期合约里就犯过这个错,基类构造函数初始化一个值,结果子类重写后读取到的是默认值,排查了很久。
继承和组合怎么选?单一的小工具模块用组合更直观,比如合约内部持有另一个合约实例,通过地址调用。但以太坊合约在存储和Gas上有天然限制,如果大量同类合约都要复用同一套复杂逻辑,继承在代码简洁性和部署成本上更有优势。现实里OpenZeppelin之所以被广泛使用,就是因为大家约定好了同一个继承结构,审计团队看代码也更快。
5. 拉到一起:一个同时用上四个机制的收藏品合约
5.1 完整示例合约
下面这个合约,把前面四个知识点串起来:继承了ERC721和Ownable,引入了自定义错误,所有外部校验都没有用require字符串:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; contract GameItem is ERC721, Ownable { uint256 public nextTokenId; error NotOwner(uint256 tokenId); error CharacterAlreadyMinted(uint256 characterId); mapping(uint256 => bool) private _characterMinted; constructor() ERC721("GameItem", "GIT") Ownable(msg.sender) {} function mintItem(uint256 characterId) external onlyOwner returns (uint256) { if (_characterMinted[characterId]) { revert CharacterAlreadyMinted(characterId); } _characterMinted[characterId] = true; uint256 tokenId = nextTokenId++; _mint(msg.sender, tokenId); return tokenId; } function burnItem(uint256 tokenId) external { if (ownerOf(tokenId) != msg.sender) { revert NotOwner(tokenId); } _burn(tokenId); } }合约的逻辑很简单:管理员铸造跟随某个角色ID的唯一NFT;角色ID不能重复铸造;只有NFT持有人才能销毁自己的物品。这里继承Ownable省去了手写权限管理,继承ERC721则完整获得了标准接口。CharacterAlreadyMinted和NotOwner把错误原因带上了具体参数,链上监控一看就知道是哪个角色ID冲突、哪个tokenId不是你的。
顺便说一句,OpenZeppelin 5.0之后的Ownable构造函数要求传入初始owner,不能无参调用,这是版本升级带来的变化。如果你用旧教程的写法Ownable(),编译会直接报错。
5.2 用一次失败交易验证错误编码
把这个合约部署到测试网,然后用一个非owner地址调用mintItem(1),交易回滚后,你可以拿到原始revert字节。用Foundry模拟时大致是:
cast call <contract> "mintItem(uint256)" 1 --rpc-url <rpc> --from <nonOwner>结果里如果只看到CharacterAlreadyMinted(uint256)对应的选择器和参数,说明自定义错误的路径生效了。你也可以用cast sig先算出错误签名,再对着revert数据核对:
cast sig "CharacterAlreadyMinted(uint256)"这并不仅限于测试工具链。生产环境里,你可以对已知错误的选择器做链上监控,一旦某个地址频繁触发NotOwner(uint256),就能推断出有人在尝试操作不属于自己的NFT,提前察觉攻击或误操作。
5.3 四个机制配合使用时的实际取舍
把上面这些机制放在同一个项目里的经验,我大致是这几条。
- 先判断资产是Coin还是Token,再决定要不要写合约。纯Coin场景往往只需要处理协议层转账,不需要发一个标准代币。
- 选标准时不要贪多。能用ERC20解决的问题不要上ERC721,能用ERC721解决的问题不要硬套ERC1155。每多一个标准,就多一层账本和权限设计。
- 自定义错误优先于require字符串。但也不要为了“省gas”把所有校验都改成error,很多只出现一次的地方用require可读性反而高。原则是:高频失败路径和需要带参数的错误用error,一次性简单校验用require。
- 继承结构是架构决策。能继承标准库就继承,能组合逻辑就不随意扩继承树。在升级场景里保持继承顺序稳定,甚至比函数实现稳定更重要。
最后再分享一个日常习惯:在合约里定义error的时候,我会把事件日志也一并想好。错误负责拒绝,事件负责记录历史。两者配合,链路追踪才能又快又准。如果你也正在写自己的第一个Web3项目,建议就从这四个概念出发,先不做复杂业务,把账本、错误和继承的边界彻底跑通再去碰更花哨的玩法。