Crypto OS
Technical Crypto OS第二阶段 · Smart Contract Engineering

T9 · ERC Standards

标准到底解决了什么问题?

练习的能力
BuilderProtocol Literacy
动手
实现一个 ERC20,再故意写一个不返回 bool 的转账函数,看哪些调用方会因此失败。
AI Lab
让 AI 列出 ERC20 的常见非标准实现,自己在主网上找出一个真实例子。

一个现实问题

你做了一个质押池,文档上写着「支持任意 ERC20 代币」。你的核心逻辑很简单:

token.transferFrom(msg.sender, address(this), amount);
shares[msg.sender] += amount;

上线三天,三件事依次发生。

第一天:有人存进一个主流稳定币,交易直接失败,报错信息里什么也看不出来。你的代码一个字没动,换个代币就好了。

第二天:有人存进一个「每次转账收 3% 税」的代币。合约收到了 97,账上记了 100。池子从这一刻起资不抵债,而且没有任何报警。

第三天:有人存进一个余额会自己变的代币。用户存了 100,第二天来取,取到了 105,多出来的 5 是从别人的份额里挖走的。

你写的是同一段代码。标准明明说了 transferFrom 应该怎么用,为什么还是出事?

思想实验

先假设一个没有标准的世界。

每个发币的人自己定义动作名:有人叫 send,有人叫 pay,有人叫 moveTokens,参数顺序也各不相同。

这个世界里,钱包要显示 100 种代币,就要写 100 份适配代码。交易所要上架 100 种代币,再写 100 份。借贷协议再写 100 份。成本是 N 乘以 M,而且每来一个新代币,所有人都得改代码、重新发版。

现在引入标准:约定好动作叫 transfer,参数是「给谁」和「多少」,还有一个 balanceOf 用来查余额。

成本瞬间从 N 乘 M 变成 N 加 M。更重要的是一件几乎像魔法的事发生了:

一个在 2018 年写好的交易所合约,可以和一个在 2025 年才被创造出来的代币交互,双方作者素未谋面,也没有任何一方需要升级。

这就是可组合性。它不是「功能更多」,而是你写代码的时候,不需要知道对方是谁

但接下来是这一章真正的转折。仔细看看标准到底约定了什么:它约定了函数叫什么名字、收什么参数、返回什么类型。它没有、也无法约定这个函数执行完之后世界会变成什么样

一个代币完全可以有一个签名完美的 transfer,而它做的事是:扣掉 3% 手续费、或者顺便调用接收方的某个函数、或者干脆什么也不做只发个事件。

签名一致,不等于行为一致。 上面那三天发生的事,全部源自这一句。

  1. 标准约定了接口
  2. 接口带来可组合性
  3. 但接口约束不了行为
  4. 所以集成方必须自己验证结果
四步里第三步最容易被跳过,而所有的坑都在那里。

你来决定

你的质押池怎么接收代币?四种做法。

观察结果

把四种做法和三天的事故摆在一起:

做法挡住不返回值挡住转账扣费挡住余额自变挡住恶意代币
直接调用
兼容返回值
余额差记账
白名单大部分

两条结论:

第一,标准给你的是一个接口,不是一个保证。 它让你的代码「能调用」任何代币,不代表任何代币「能被安全地集成」。调用能力和信任是两件事,标准只提供前者。

第二,防御的层次是递进的,不是互斥的。 成熟协议的做法是全上:用兼容写法发调用,用余额差记账,再用白名单限制范围。三层各挡一类问题,少一层就多一类事故。

这三层会在这一阶段之后反复出现:T19 的 AMM 要处理扣费代币的滑点计算,T22 的借贷协议要处理余额自变的抵押品。现在多花十分钟理解,后面能省掉两章的困惑。

建立模型

标准的三层

一个「符合 ERC20」的代币,实际上在三个层次上做了不同强度的承诺:

内容强制程度
接口有哪些函数、参数类型、返回类型、事件编译器能检查,但没人强制你实现对
语义这些函数应该做什么、余额守恒、事件何时发完全靠自觉,链上不检查
生态钱包会怎么显示、浏览器会怎么解析、DEX 会怎么定价由别人的代码决定,不由你决定

绝大多数事故都发生在第二层:接口对了,语义没对。

三个代币标准的分工

ERC20ERC721ERC1155
单位可分割的数量不可分割的单个物品两者都行
身份只有余额,没有编号每一个都有唯一 ID每个 ID 下有数量
转账数量特定 ID一次多个 ID 和数量
授权按数量给某个地址单个 ID 或全部全部
接收方保护转给合约时会回调确认同左,且支持批量

一句话记忆:ERC20 管「多少」,ERC721 管「哪一个」,ERC1155 管「哪一批的多少个」。

注意 ERC721 和 ERC1155 那个「转给合约时会回调确认」的设计——它是为了防止 NFT 被转进一个取不出来的合约而加的,但它同时意味着转账过程中会把执行权交给接收方。这是一个安全上必须知道的性质,T28 会展开。

授权模型

合约不能主动伸手到你账户里拿钱,所以 ERC20 用了两步:

第一步  你 → 代币合约:allowance[你][协议] = 100
第二步  你 → 协议:帮我存 100
        协议 → 代币合约:transferFrom(你, 协议, 100)
        代币合约检查 allowance 够不够,扣掉,转账

这个设计解决了「合约怎么拿钱」,同时制造了三个真实问题:

  1. 两笔交易。 用户第一次接触任何协议都要签两次,体验差,而且第一笔的 Gas 纯属额外成本。
  2. 额度会留下。 用完之后额度还在。绝大多数钱包默认授权无限额度,于是你的钱包里躺着几十个「某某协议可以随时拿走我全部某某代币」的许可,而那些协议可能早就不维护了。
  3. 改额度有竞态。 把额度从 100 改成 50 时,对方可能在你的修改交易之前先花掉 100,改完再花 50。

Permit 是对第 1 点的回应:用户对一份带 nonce 和过期时间的授权消息签名(不发交易、不花 Gas),协议在自己的那笔交易里把签名一起带上,代币合约验签后直接设置额度。两笔变一笔。

对第 2 点的回应不在代码里,而在习惯里:定期检查并撤销不再需要的授权T13 会让你做一次。

一份最小的 ERC20

把上面所有东西落成代码。这份实现不依赖任何外部库,可以直接编译:

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

contract MiniToken {
    string public name = "Mini";
    string public symbol = "MINI";
    uint8 public decimals = 18;          // 注意:这不是「必须是 18」,只是「最常见是 18」

    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 initialSupply) {
        totalSupply = initialSupply;
        balanceOf[msg.sender] = initialSupply;
        // 增发按约定表示为「从零地址转出」,链下索引靠这条重建总量
        emit Transfer(address(0), msg.sender, initialSupply);
    }

    function transfer(address to, uint256 value) external returns (bool) {
        _transfer(msg.sender, to, value);
        return true;
    }

    function approve(address spender, uint256 value) external returns (bool) {
        allowance[msg.sender][spender] = value;
        emit Approval(msg.sender, spender, value);
        return true;
    }

    function transferFrom(address from, address to, uint256 value) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        if (allowed != type(uint256).max) {        // 无限额度不递减,省一次存储写入
            require(allowed >= value, "allowance");
            allowance[from][msg.sender] = allowed - value;
        }
        _transfer(from, to, value);
        return true;
    }

    function _transfer(address from, address to, uint256 value) internal {
        require(to != address(0), "to zero");
        uint256 bal = balanceOf[from];
        require(bal >= value, "balance");
        balanceOf[from] = bal - value;
        balanceOf[to] += value;
        emit Transfer(from, to, value);
    }
}

三个细节值得停一下:

  • 增发写成「从零地址转出」,销毁写成「转给零地址」。这不是代码要求,是生态约定——链下索引器全都按这个规则重建总量,不遵守它,你的代币在所有数据平台上都会显示错误。
  • decimals 不是 18。 它只是最常见的值。把 18 硬编码进你的协议,遇到精度为 6 或 8 的代币就会错几个数量级。
  • 无限额度不递减是一个广泛存在的优化,但它也意味着「授权一次,永久有效」。

它叫什么

ERC20同质化代币标准

定义可分割代币的接口:余额、转账、授权、两个事件。

它是整个链上金融的最小公共分母。它约定接口,不约定行为——这一句是本章的全部重点。

ERC721非同质化代币标准

每一个代币有唯一 ID,不可分割。所有权是「谁持有这个 ID」,而不是「谁有多少」。

它引入了转给合约时的接收确认回调,这个回调既是保护,也是一个把执行权交给外部的入口。

ERC1155多代币标准

同一份合约里管理多个 ID,每个 ID 下可以有数量,支持批量转账。

适合「一套东西里有很多种」的场景:游戏道具、票据、一批同款凭证。批量转账能显著降低成本。

Allowance / Approve授权额度

持有人授权某个地址可以从自己账户里转走多少。

三个必须记住的性质:默认是无限额度(钱包的锅,不是标准的锅)、用完不会自动清零修改额度存在竞态

Permit签名授权

把授权从一笔交易变成一个离线签名,由协议在自己的交易里代为提交。

它省掉了用户的一笔交易,但也带来新的钓鱼形态:用户看到的是「只是签个名」,实际签下去的可能是一份无限额度授权。界面必须如实展示签名内容,T13 会细讲。

Composability可组合性

不同作者、不同时间写的合约,因为共享同一套接口而能够互相调用。

它是标准存在的唯一理由,也是这一章的标题答案:标准解决的不是功能问题,是「素未谋面的两段代码如何协作」的问题。

动手

动手实现一个 ERC20,再故意写一个不返回 bool 的版本,看谁会失败任意 Solidity 开发框架 + 本地节点0 元,全程本地与测试网

这个 Lab 的目的是让你亲眼看到「签名不一致」和「行为不一致」分别长什么样。

把上面那份 MiniToken 部署到本地。 跑通转账、授权、代扣三条路径,确认事件都发出来了。

复制一份,改成不返回 bool 的版本。 只改函数签名,逻辑一个字不动:

function transfer(address to, uint256 value) external {
    _transfer(msg.sender, to, value);
}

这个版本在链上完全能用,转账也真的发生了。它只是不符合接口声明。

写两个调用方,分别调用它们。

interface IERC20 {
    function transfer(address to, uint256 value) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

// 严格版:按接口声明调用
contract StrictConsumer {
    function push(address token, address to, uint256 value) external {
        require(IERC20(token).transfer(to, value), "transfer failed");
    }
}

// 兼容版:低级调用,自己判断返回数据
contract TolerantConsumer {
    function push(address token, address to, uint256 value) external {
        (bool ok, bytes memory data) = token.call(
            abi.encodeWithSignature("transfer(address,uint256)", to, value)
        );
        // 要么没有返回数据,要么返回数据解出来是 true
        require(ok && (data.length == 0 || abi.decode(data, (bool))), "transfer failed");
    }
}

把两个调用方 乘 两种代币,四种组合各跑一遍,记下哪几种失败、失败在哪一步。

严格版遇到不返回值的代币会回滚,因为返回数据不够解码出一个 bool。这就是第一天那笔交易的真相。

再做一个扣费版。_transfer 里扣掉 3% 转给一个收费地址,其余逻辑不变。

现在用两个调用方分别调它:两个都会成功。返回值是 true,事件也发了,一切看起来完美,但到账金额少了 3%。

这一步是整个 Lab 的高潮:你的防御代码对它完全无效。

加上余额差记账,再跑一遍。 写一个 push 的第三版,转账前后各读一次接收方余额,用差值作为「实际到账」。

对三种代币分别跑,确认三种都能得到正确的到账数字。

部署到测试网,去区块浏览器上看。 看一眼两种代币在浏览器上的显示有没有区别——多数情况下没有区别,因为浏览器只看事件。

能被正确显示,不代表能被安全集成。 这一眼值得看。全程测试网,不要用真实资产。

AI Lab

AI Lab让 AI 列出 ERC20 的常见非标准实现,你去主网上找出一个真实例子Level 2 · AI Copilot

分两步,第二步是真正干活的那一步。

第一步:
列出 ERC20 在真实世界里的常见非标准实现。每一类说明:
它违反了标准的哪一条、会让哪些调用方出错、正确的防御写法是什么。
按「真实世界里出现频率」从高到低排序。

第二步:
对第一类,给出你确信存在于主网的例子,并说明我该怎么自己验证它确实是这样。
如果你不确定某个具体合约是不是这样,明确说不确定。

第一步模型通常给得很全:不返回布尔值、返回 false 而不回滚、转账扣费、余额自变、可暂停、有黑名单、精度不是 18、允许零地址、授权必须先清零才能改。这份清单本身就值得贴在你的集成检查表上。

第二步会暴露一件重要的事。模型非常擅长生成一个格式完全正确、校验和也对的合约地址,而那个地址上什么都没有。 拿到地址之后逐个去浏览器上查,你会发现命中率低得惊人。

这不是模型「不够强」,是它在做一件它做不到的事——回忆一个 42 位的字符串。凡是需要精确回忆的东西,都要自己去核对;凡是需要归纳和枚举的东西,它比你快。 这条分界线,你在这个 Lab 里会记一辈子。

找例子的正确方法是自己动手:去区块浏览器按交易量排序看主流代币,挨个读它们的 transfer 源码。半小时之内,上面那份清单里的至少三类你都能找到真实样本。

AI 说完之后,你必须自己验证

  • 它给的每一类非标准实现,你都在主网上找到了至少一个真实合约并自己读过源码
  • 它如果直接给出合约地址,几乎一定是编的,逐个去区块浏览器上核对
  • 它有没有把「转账扣费」和「余额自变」混为一谈,这是两类完全不同的问题
  • 它写的兼容转账代码,有没有正确处理返回数据长度为 0 的情况
  • 它有没有引用不存在的 EIP 编号或库函数名,每一个都要去查
  • 它给的每一条防御措施,你都能说清它挡住了哪一类、挡不住哪一类

真实案例

不返回布尔值的大市值稳定币

市值最大的几个美元稳定币之一,它的 transfertransferFrom 没有返回值。

这份合约部署得很早,那时接口细节还没有完全定型。而它体量太大,不可能为了合规而改——于是整个生态反过来适应它:几乎所有主流协议的转账封装,都是为了兼容这一个代币而存在的。

这件事的启示不是「那份代码写错了」,而是:在一个不可变的系统里,先行者的实现会变成事实标准,哪怕它和书面标准不一致。

转账扣费的代币让池子记账错误

某些代币在每次转账时抽取一定比例,用于分红、销毁或营销钱包。

amount 记账的 AMM 和借贷池会认为自己收到了全额。缺口一开始很小,随着交易量累积,最终表现为「最后一批想退出的人取不出钱」。

更隐蔽的是反向路径:池子往外转账时也会被扣,于是连输出金额的计算都是错的。这类代币在多数主流协议上是被明确不支持的,而这正是白名单存在的理由。

带回调的代币标准引发重入2020 年,某借贷协议

有一种代币标准在转账时会主动调用接收方的一个钩子函数。它的初衷是让合约能感知到「我收到钱了」。

某借贷协议接受了一种这样的代币作为抵押品。攻击者在钩子里回头再调一次借贷协议的存款函数,在第一次存款的账还没记完时,把同一笔钱重复记了多次,然后借走了池子里的其他资产。

两条教训:代币的转账不一定是一个「原子且安静」的操作;以及集成任何代币前,都要问一句「它的 transfer 会不会把执行权交给别人」。完整的重入模型在 T28。

无限授权 + 钓鱼签名

钱包默认申请无限额度,用户点一次「同意」就永久生效。几年下来,一个活跃地址会积累几十上百条这样的许可。

攻击者的做法不是黑掉协议,而是让你在一个仿冒的页面上签一份 permit,或者诱导你授权给一个恶意合约。签完之后什么也不会发生,钱是在几天后、你早已忘记这件事的时候被转走的。

防御不在合约里,在两个地方:钱包界面必须如实展示签名内容;用户必须养成定期撤销的习惯。

改一个变量

如果代币的 decimals 是 6 而不是 18

所有把 18 硬编码进去的计算全部错三个数量级。价格算错、清算线算错、界面显示错。

正确做法是每次都从代币合约读一次 decimals 并缓存,而不是假设。顺带一提:decimals 在标准里是可选的,存在一小部分代币根本没有这个函数——读它之前得能容忍调用失败。

如果代币可以被发行方暂停转账

你的协议在暂停期间会全面卡死:存不进、取不出、清算不了。

最危险的组合是「抵押品可被暂停 + 你的清算依赖转账」:价格暴跌时清算执行不了,坏账直接落在协议身上。

这就是为什么研究一个代币时,读完 transfer 还要读一遍它有哪些管理员函数

如果代币合约本身是可升级的

今天符合标准,明天就可能不符合。你的所有集成假设都建立在一份随时可能被替换的代码上。

这不代表不能用可升级代币——主流稳定币大多是可升级的。它代表你的风险清单上要多一条:谁能升级它,多久生效,有没有延时。 这一条属于 T29 的协议安全范畴。

如果你的协议只接受白名单代币

三天里的三个事故一次性消失。代价是失去了「任何人都能上架」这个卖点。

真正的问题变成了治理问题:谁能往白名单里加代币,加错了谁负责,能不能被一次投票加进一个恶意代币。 很多协议的事故根因不在代码,在这个流程上。

开放性和安全性在这里是一个真实的取舍,没有免费的答案——但先默认封闭,按需开放,通常比反过来更容易收场。

带走的问题

1
它解决什么问题?

标准解决什么问题?不是「让代币有更多功能」,而是让素未谋面的两段代码能够协作。判断一个新标准值不值得关注,问的也是这个:它让哪两类原本无法协作的东西接上了。

3
为什么需要 Token?

为什么需要 Token?这一章给出的是工程视角的答案:Token 是一段被所有人共享的记账接口。它之所以有用,是因为它可以被别人的代码直接消费,而不需要经过你的许可或者你的 API。

9
谁承担风险?

谁承担风险?集成方。代币作者可以写任何行为,出事时资金是从你的池子里流走的。所以这一章的三层防御不是可选项:兼容写法、余额差记账、白名单,每一层对应一类你必须替用户挡住的风险。

本章自测

一句话带走

标准是可组合性的前提,不是功能清单。

做完这一章的动手环节了?勾上它查看全部进度

本页目录