跳转到主要内容

ERC-20 合约详解

Solidity
erc-20
初学者
Ori Pomerantz
2021年3月9日
36 分钟阅读

简介

以太坊最常见的用途之一是由一个团队来打造一种可以交易的代币,在某种意义上是他们自己的货币。 这些代币通常遵循一个标准: ERC-20。 此标准使得编写适用于所有 ERC-20 代币的工具(例如流动性资金池和钱包)成为可能。 在本文中,我们将分析 OpenZeppelin Solidity ERC20 实现 (opens in a new tab)以及 接口定义 (opens in a new tab)

这是带注释的源代码。 如果你想实现 ERC-20, 请阅读本教程 (opens in a new tab)

接口

ERC-20 等标准的目的是让多种代币实现能够在应用程序(如钱包和去中心化交易所)之间互操作。 为实现此目的,我们创建一个 接口 (opens in a new tab)。 任何需要使用代币合约的代码 都可以在接口中使用相同的定义,并与所有使用它的代币合约兼容,无论它是像 MetaMask 这样的钱包,像 etherscan.io 这样的去中心化应用程序,还是像流动性资金池这样不同的合约。

ERC-20 接口图示

如果你是一位经验丰富的程序员,你可能还记得在 Java (opens in a new tab) 甚至 C 头文件 (opens in a new tab)中见过类似的构造。

这是 OpenZeppelin 的 ERC-20 接口 (opens in a new tab) 定义。 它是将人类可读标准 (opens in a new tab)翻译成 Solidity 代码。 当然, 接口本身并不定义_如何_做任何事情。 这一点在下文合约的源代码中作了解释。

 

// SPDX-License-Identifier: MIT

Solidity 文件应该包含许可证标识符。 你可以在此处查看许可证列表 (opens in a new tab)。 如果你需要其他 许可证,只需在注释中说明即可。

 

pragma solidity >=0.6.0 <0.8.0;

Solidity 语言仍在快速发展,新版本可能与旧代码不兼容 (在此处查看 (opens in a new tab))。 因此,最好不仅指定语言的最低 版本,还指定最高版本,即你测试过代码的最新版本。

 

/**
 * @dev EIP 中定义的 ERC20 标准接口。
 */

注释中的 @devNatSpec 格式 (opens in a new tab) 的一部分,用于从源代码生成 文档。

 

interface IERC20 {

按照惯例,接口名称以 I 开头。

 

    /**
     * @dev 返回存在的代币数量。
     */
    function totalSupply() external view returns (uint256);

此函数是 external,表示它只能从合约外部调用 (opens in a new tab)。 它返回合约中代币的总供应量。 这个值按以太坊中最常见的类型返回,即无符号的 256 位(256 位是 以太坊虚拟机的原生字长)。 此函数也是 view 函数,意味着它不会改变状态,所以它可以在单个节点上执行,而不必让 区块链中的每个节点都运行它。 这类函数不会产生交易,也不消耗燃料

**注意:**理论上,合约创建者似乎可以通过返回比实际值小的总供应量来作弊,使每个代币看起来 比实际更有价值。 然而,这种担忧忽视了区块链的真正本质。 区块链上发生的一切都可以由 每个节点验证。 为实现这一点,每个合约的机器语言代码和存储都位于每个节点上。 虽然你不需要发布合约的 Solidity 代码,但除非你发布源代码以及用于编译它的 Solidity 版本,否则没有人会把你当回事,这样才能根据你提供的机器语言代码进行 验证。 例如,请参阅此合约 (opens in a new tab)

 

    /**
     * @dev 返回 `account` 拥有的代币数量。
     */
    function balanceOf(address account) external view returns (uint256);

顾名思义,balanceOf 返回账户余额。 以太坊账户在 Solidity 中使用 address 类型进行标识,该类型占 160 位。 它也是 externalview

 

    /**
     * @dev 将 `amount` 数量的代币从调用者的账户转移到 `recipient`。
     *
     * 返回一个布尔值,表示操作是否成功。
     *
     * 触发一个 {Transfer} 事件。
     */
    function transfer(address recipient, uint256 amount) external returns (bool);

transfer 函数将代币从调用者转移到另一个地址。 这涉及状态变更,所以它不是 view 函数。 当用户调用此函数时,它会创建交易并消耗燃料。 它还会触发一个 Transfer 事件,将该事件通知 区块链上的所有人。

该函数有两种输出,对应两种不同的调用者:

  • 直接从用户界面调用函数的用户。 通常,用户提交交易后 不会等待响应,因为响应可能需要不确定的时间。 用户可以 通过查找交易收据(通过交易哈希值识别)或查找 Transfer 事件来查看发生了什么。
  • 将函数作为整个交易一部分调用的其他合约。 这些合约会立即获得结果, 因为它们在同一笔交易中运行,所以它们可以使用函数返回值。

更改合约状态的其他函数会创建相同类型的输出。

 

授权额度允许一个账户花费属于其他所有者的代币。 例如,对于充当卖方的合约,这很有用。 合约无法 监控事件,因此如果买方将代币直接转移给卖方合约 ,该合约不会知道它已收到付款。 因此,买方授权 卖方合约花费一定金额,然后卖方转走该金额。 这是通过卖方合约调用的函数完成的,因此卖方合约 可以知道它是否成功。

    /**
     * @dev 返回 `spender` 通过 {transferFrom} 可代表 `owner` 花费的
     * 剩余代币数量。默认
     * 为零。
     *
     * 调用 {approve} 或 {transferFrom} 时,此值会发生变化。
     */
    function allowance(address owner, address spender) external view returns (uint256);

allowance 函数允许任何人查询一个 地址(owner)授权另一个地址(spender)花费的额度。

 

approve 函数创建一个授权额度。 请务必阅读有关 如何滥用它的消息。 在以太坊中,你可以控制自己交易的顺序, 但无法控制其他人交易的执行顺序, 除非在看到对方的交易发生之前 不提交自己的交易。

 

最后,transferFrom 由花费者用来实际花费授权额度。

 

当 ERC-20 合约的状态发生变化时,会触发这些事件。

实际合约

这是实现 ERC-20 标准的实际合约, 摘自此处 (opens in a new tab)。 它不应按原样使用,但你可以 继承 (opens in a new tab)它以将其扩展为可用的东西。

// SPDX-License-Identifier: MIT
pragma solidity >=0.6.0 <0.8.0;

 

Import 语句

除了上面的接口定义,合约定义还导入了另外两个文件:


import "../../GSN/Context.sol";
import "./IERC20.sol";
import "../../math/SafeMath.sol";
  • GSN/Context.sol 是使用 OpenGSN (opens in a new tab) 所需的定义,该系统允许没有以太币的用户 使用区块链。 请注意,这是一个旧版本,如果你想与 OpenGSN 集成 请使用本教程 (opens in a new tab)
  • SafeMath 库 (opens in a new tab),用于防止 Solidity <0.8.0 版本中的 算术上溢/下溢。 在 Solidity ≥0.8.0 中,算术运算在 上溢/下溢时会自动还原,因此不再需要 SafeMath。 此合约使用 SafeMath 是为了与 旧版编译器向后兼容。

 

此注释说明了合约的目的。

合约定义

contract ERC20 is Context, IERC20 {

此行为 OpenGSN 指定继承,在本例中来自上面的 IERC20Context

 


    using SafeMath for uint256;

此行将 SafeMath 库附加到 uint256 类型。 你可以在 此处 (opens in a new tab)找到此库。

变量定义

这些定义指定了合约的状态变量。 这些变量被声明为 private,但这只意味着区块链上的其他合约无法读取它们。 区块链上 没有秘密,每个节点上的软件都有每个区块中 每个合约的状态。 按照惯例,状态变量命名为 _<something>

前两个变量是映射 (opens in a new tab), 这意味着它们的行为大致与关联数组 (opens in a new tab)相同, 只是键是数值。 存储空间仅分配给值不同于 默认值(零)的条目。

    mapping (address => uint256) private _balances;

第一个映射 _balances 是地址及其各自的此代币余额。 要访问 余额,请使用此语法:_balances[<address>]

 

    mapping (address => mapping (address => uint256)) private _allowances;

此变量 _allowances 存储前面解释的授权额度。 第一个索引是 代币的所有者,第二个索引是具有授权额度的合约。 要访问地址 A 可以 从地址 B 的账户中花费的金额,请使用 _allowances[B][A]

 

    uint256 private _totalSupply;

顾名思义,此变量记录代币供应总量。

 

    string private _name;
    string private _symbol;
    uint8 private _decimals;

这三个变量用于提高可读性。 前两项的含义不言自明,但 _decimals 并非如此。

一方面,以太坊没有浮点或分数变量。 另一方面, 人们希望能够拆分代币。 人们选择将黄金作为货币的一个原因是, 当有人想用牛换鸭子时,很难找零。

解决方案是跟踪整数,但计数时使用一个价值非常小的分数代币, 而不是真正的代币。 就以太币而言,分数代币称为 wei,10^18 个 wei 等于一个 ETH。 在撰写本文时,10,000,000,000,000 wei 约等于一美分或欧分。

应用程序需要知道如何显示代币余额。 如果某位用户有 3,141,000,000,000,000,000 wei,那是否是 3.14 个 ETH? 31.41 个 ETH? 3,141 个 ETH? 对于以太币,10^18 wei 等于 1 个 ETH,但对于你的 代币,你可以选择一个不同的值。 如果拆分代币没有意义,你可以将 _decimals 值设为零。 如果想要使用与 ETH 相同的标准,请使用值 18

构造函数

构造函数在首次创建合约时调用。 按照惯例,函数参数名为 <something>_

用户界面函数

这些函数,namesymboldecimals 帮助用户界面了解你的合约,以便它们能够正确显示。

返回类型是 string memory,表示返回存储在内存中的字符串。 变量,例如 字符串,可以存储在三个位置:

生命周期合约访问燃料成本
内存函数调用读/写几十到几百不等(位置越高成本越高)
调用数据函数调用只读不能用作返回类型,只能用作函数参数类型
存储直到被修改读/写高(读取为 800,写入为 2 万)

在这种情况下,memory 是最好的选择。

读取代币信息

这些是提供代币信息的函数,无论是总供应量还是 账户余额。

    /**
     * @dev 请参阅 {IERC20-totalSupply}。
     */
    function totalSupply() public view override returns (uint256) {
        return _totalSupply;
    }

totalSupply 函数返回代币的总供应量。

 

    /**
     * @dev 请参阅 {IERC20-balanceOf}。
     */
    function balanceOf(address account) public view override returns (uint256) {
        return _balances[account];
    }

读取账户余额。 请注意,任何人都可以获取其他任何人的账户 余额。 试图隐藏此信息没有意义,因为它在每个节点上 都是可见的。 区块链上没有秘密。

转移代币

transfer 函数用于将代币从发送者的账户转移到另一个账户。 请注意 ,即使它返回一个布尔值,该值也始终为 true。 如果转移 失败,合约将还原调用。

 

        _transfer(_msgSender(), recipient, amount);
        return true;
    }

_transfer 函数执行实际工作。 它是一个私有函数,只能由 其他合约函数调用。 按照惯例,私有函数命名为 _<something>,与状态 变量相同。

通常在 Solidity 中,我们使用 msg.sender 表示消息发送者。 然而,这会破坏 OpenGSN (opens in a new tab)。 如果我们想允许使用我们的代币进行无以太币交易,我们 需要使用 _msgSender()。 对于正常交易,它返回 msg.sender,但对于无以太币的交易, 它返回原始签名者而不是中继消息的合约。

授权额度函数

这些是实现授权额度功能的函数:allowanceapprovetransferFrom_approve。 此外,OpenZeppelin 实现超出了基本标准,包含了一些提高 安全性的功能:increaseAllowancedecreaseAllowance

allowance 函数

    /**
     * @dev 请参阅 {IERC20-allowance}。
     */
    function allowance(address owner, address spender) public view virtual override returns (uint256) {
        return _allowances[owner][spender];
    }

allowance 函数允许任何人检查任何授权额度。

approve 函数

    /**
     * @dev 请参阅 {IERC20-approve}。
     *
     * 要求:
     *
     * - `spender` 不能是零地址。
     */
    function approve(address spender, uint256 amount) public virtual override returns (bool) {

此函数用于创建授权额度。 它与上面的 transfer 函数相似:

  • 该函数仅调用一个执行实际工作的内部函数(本例中为 _approve)。
  • 函数要么返回 true(如果成功),要么还原(如果失败)。

 

        _approve(_msgSender(), spender, amount);
        return true;
    }

我们使用内部函数来尽量减少发生状态变化的地方。 _任何_改变 状态的函数都是潜在的安全风险,需要进行安全审计。 这样我们出错的机会就更少了。

transferFrom 函数

这个函数由花费者调用以花费授权额度。 这需要两个操作:转移花费的金额 并从授权额度中减去该金额。

 

a.sub(b, "message") 函数调用做了两件事。 首先,它计算 a-b,这是新的授权额度。 其次,它检查此结果是否为负数。 如果结果为负,将还原调用,并发出提供的消息。 请注意,当调用还原时,该调用期间先前完成的任何处理都将被忽略,因此我们不需要 撤销 _transfer

        _approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount,
             "ERC20: transfer amount exceeds allowance"));
        return true;
    }

OpenZeppelin 安全补充

将非零授权额度设置为另一个非零值是危险的, 因为你只能控制自己交易的顺序,而不能控制其他人的交易顺序。 假设现在有两个用户,天真的 Alice 和不诚实的 Bill。 Alice 想要从 Bill 处获取一些服务, 她认为值五个代币,所以她给了 Bill 五个代币的授权额度。

之后有了一些变化,Bill 的价格提高到了十个代币。 Alice 仍然想要购买服务,就发送了一笔交易,将 Bill 的授权额度设置为 10。 当 Bill 在交易池中看到这个新的交易时, 他就会发送一笔交易,以花费 Alice 的五个代币,并且设定高得多的 燃料价格,这样就会更快挖出。 这样,Bill 可以先花五个代币,然后 当 Alice 的新授权额度被挖出后,他就可以再花费十个代币,这样总共花费了 15 个代币, 超过了 Alice 本欲授权的金额。 这种技术称为 抢先交易 (opens in a new tab)

Alice 的交易Alice 的随机数Bill 的交易Bill 的随机数Bill 的授权额度Bill 从 Alice 处获得的总收入
approve(Bill, 5)1050
transferFrom(Alice, Bill, 5)10,12305
approve(Bill, 10)11105
transferFrom(Alice, Bill, 10)10,124015

为避免此问题,这两个函数(increaseAllowancedecreaseAllowance)允许你 修改指定数额的授权额度。 所以,如果 Bill 已经花费了五个代币, 他就只能再花五个代币。 根据时间的不同,有两种方法可以生效, 这两种方法都会使 Bill 最终只得到十个代币:

A:

Alice 的交易Alice 的随机数Bill 的交易Bill 的随机数Bill 的授权额度Bill 从 Alice 处获得的总收入
approve(Bill, 5)1050
transferFrom(Alice, Bill, 5)10,12305
increaseAllowance(Bill, 5)110+5 = 55
transferFrom(Alice, Bill, 5)10,124010

B:

Alice 的交易Alice 的随机数Bill 的交易Bill 的随机数Bill 的授权额度Bill 从 Alice 处获得的总收入
approve(Bill, 5)1050
increaseAllowance(Bill, 5)115+5 = 100
transferFrom(Alice, Bill, 10)10,124010

a.add(b) 函数是一个安全加法。 在 a+b>=2^256 的罕见情况下,它不会像 普通加法那样发生环绕。

修改代币信息的函数

这些是执行实际工作的四个函数:_transfer_mint_burn_approve

_transfer 函数

这个函数 _transfer 将代币从一个账户转移到另一个账户。 它由 transfer(用于从发送者自己的账户转移)和 transferFrom(用于使用授权额度 从其他人的账户转移)调用。

 

        require(sender != address(0), "ERC20: transfer from the zero address");
        require(recipient != address(0), "ERC20: transfer to the zero address");

实际上以太坊中没有人拥有零地址(即不存在对应公钥可以转换为零地址的私钥)。 当人们使用该地址时,通常是一个软件漏洞,所以 如果将零地址用作发送人或接收人,交易将失败。

 

        _beforeTokenTransfer(sender, recipient, amount);

使用该合约有两种方法:

  1. 将其用作你自己的代码的模板
  2. 继承它 (opens in a new tab),并仅覆盖你需要修改的那些函数

第二种方法要好得多,因为 OpenZeppelin ERC-20 代码已经过审核,其安全性也已得到证实。 当你使用继承时, 你可以清楚地表明你修改了哪些函数,而信任你的合约的人只需要审核那些特定的函数。

代币每次易手时,执行一个函数通常很有用。 然而,_transfer 是一个非常重要的函数, 编写它可能会不安全(见下文),所以最好不要覆盖它。 解决方案是 _beforeTokenTransfer,一个 钩子函数 (opens in a new tab)。 你可以覆盖此函数,每次转移都会调用它。

 

        _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
        _balances[recipient] = _balances[recipient].add(amount);

这些是实际执行转移的代码。 请注意,它们之间没有任何东西,并且我们在将转账金额加到接收人账户之前,先从发送人账户中扣除 。 这很重要,因为如果 中间调用了另一个合约,可能会被用来骗过这个合约。 这样,转移 是原子性的,中间不会发生任何事情。

 

        emit Transfer(sender, recipient, amount);
    }

最后,触发一个 Transfer 事件。 智能合约无法访问事件,但区块链外运行的代码 可以监听事件并对其作出反应。 例如,钱包可以跟踪所有者何时获得更多代币。

_mint 和 _burn 函数

这两个函数(_mint_burn)修改代币的总供应量。 它们是内部函数,在此合约中没有调用它们的函数, 因此,仅当你继承该合约并添加自己的 逻辑来决定在何种条件下铸造新代币或销毁现有 代币时,它们才有用。

**注意:**每个 ERC-20 代币都有自己的业务逻辑来决定代币管理。 例如,一个固定供应量的合约可能只在构造函数中调用 _mint ,而从不调用 _burn。 一个销售代币的合约 将在收到付款时调用 _mint,并大概在某个时间点调用 _burn, 以避免失控的通货膨胀。

当代币总数发生变化时,请务必更新 _totalSupply

 

_burn 函数与 _mint 函数几乎完全相同,但它们的方向相反。

_approve 函数

这是实际指定授权额度的函数。 请注意,它允许所有者指定 一个高于所有者当前余额的授权额度。 这是可以的,因为余额在 转移时会进行检查,届时余额可能不同于创建授权额度时的 余额。

 

触发一个 Approval 事件。 根据应用程序的编写方式, 花费者合约可以从所有者或监听这些事件的服务器获知批准结果。

        emit Approval(owner, spender, amount);
    }

修改 Decimals 变量

此函数修改 _decimals 变量,该变量用于告知用户界面如何解释金额。 你应该从构造函数中调用它。 在之后的任何时候调用都是不诚实的, 应用程序也并非设计用于处理这种情况。

钩子

这是在转移过程中要调用的钩子函数。 这里是空的,但如果你需要 它做一些事情,只需覆盖它即可。

结论

为了复习,以下是此合约中一些最重要的思想(在我看来,你的可能有所不同):

  • 区块链上没有秘密。 智能合约可以访问的任何信息 都对全世界可见。
  • 你可以控制自己交易的顺序,但不能控制其他人交易发生的时间。 这就是为什么更改授权额度可能很危险,因为它让 花费者可以花费两个授权额度的总和。
  • uint256 类型的值会环绕。 换言之,0-1=2^256-1。 如果这不是期望的 行为,你必须检查它(或使用为你执行此操作的 SafeMath 库)。 请注意,这在 Solidity 0.8.0 (opens in a new tab) 中已更改。
  • 在特定位置执行特定类型的所有状态更改,因为这使审计更容易。 这就是为什么我们有 _approve,它由 approvetransferFromincreaseAllowancedecreaseAllowance 调用
  • 状态更改应是原子性的,中间没有任何其他操作(如你在 _transfer 中看到的 )。 这是因为在状态更改期间,你处于不一致的状态。 例如, 在你从发送者的余额中扣除和添加到接收者的余额之间的 时间里,存在的代币比应有的要少。 如果它们之间 有操作,特别是调用另一个合约,这可能会被滥用。

现在你已经了解了 OpenZeppelin ERC-20 合约是如何编写的, 尤其是如何使其更加安全,你可以去编写自己的安全合约和应用程序了。

点击此处查看我的更多作品 (opens in a new tab)

页面最后更新: 2026年4月15日

这篇教程对您有帮助吗?