恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

Coin与Token、ERC20与ERC721、自定义错误与合约继承:智能合约核心解析

  • 首页
  • 资讯中心
  • /
  • Coin与Token、ERC20与ERC721、自定义错误与合约继承:智能合约核心解析

相关资讯

Unity编辑器扩展:实现3D空间测量工具(面积/距离/角度) 2026/10/4 11:34:06
SwiftUI计时器跳动问题全解:从等宽数字到TimelineView 2026/10/4 11:34:06
用原生HTML+CSS+JS还原冰箱控制面板:状态管理与交互细节全解析 2026/10/4 11:34:06

最新资讯

OpenAI一夜发25项更新:常驻Agent Dots和降价版GPT-6.1 Sol
OpenClaw 一键安装包来了,代码开源!TaoToken 统一 Key 接入配置指南
DevExpress WinForms Data Grid进阶:绑定、主从表与大数据量性能优化
ROST_CM6实操指南:词频分析、语义网络与情感分析全解析
MRAM取代Flash/EEPROM:dsPIC33EP与MR25H40CDF的工业存储实战
OpenShell完全指南:在Win11/10上恢复经典开始菜单

今日推荐

MR25H40CDF + PIC18F65K40:工业记录仪高可靠存储实战
基于STM32的数控恒压恒流电源设计:从硬件到PID调参全解析
LT9211 MIPI重定时器原理与双路扇出实战指南

本周热门

MR25H40CDF + PIC18F65K40:工业记录仪高可靠存储实战
基于STM32的数控恒压恒流电源设计:从硬件到PID调参全解析
LT9211 MIPI重定时器原理与双路扇出实战指南

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

Coin与Token、ERC20与ERC721、自定义错误与合约继承:智能合约核心解析

发布时间:2026/10/4 11:34:06
Coin与Token、ERC20与ERC721、自定义错误与合约继承:智能合约核心解析 有个朋友拿合约代码来找我说他把游戏里的专属道具做成了代币结果玩家发现同一件限量装备能被拆分出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 零地址和WETHCoin进入Token生态的桥很多交易协议在处理“ETH兑换某个ERC20代币”时会用一个约定用零地址address(0)代表ETH。为什么因为ETH本身不是ERC20没有balanceOf、transfer这些标准函数无法塞进通用的代币路由逻辑里。为了解决这个问题就有了WETHWrapped 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对比项ERC20ERC721代币单位可拆分、有decimals不可拆分、tokenId唯一记账核心balanceOf allowanceownerOf approvals转账语义转移数量转移某个具体资产典型场景积分、支付、治理、流动性藏品、票据、身份、装备存储开销一个地址一行余额每个资产多行状态选型时我会先问自己这东西能被拆分成0.5个还合理吗合理用ERC20不合理用ERC721。如果是一类资产、每个个体有共性但又有差异比如游戏里同一种皮肤但有不同的编号那么ERC721更合适如果你的业务是积分、点数、治理投票权ERC20是自然选择。还有介于两者之间的场景比如半同质化的游戏道具批量生成可以看ERC1155但这篇文章不展开。3. 自定义错误机制从Error(string)到Panic再到自定义error3.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是除以00x32是数组下标越界。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参数的也就是46468字节。对比一下前面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, AA、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项目建议就从这四个概念出发先不做复杂业务把账本、错误和继承的边界彻底跑通再去碰更花哨的玩法。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号