跳转至主要内容

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 接口图示

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

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

 

1// SPDX-License-Identifier: MIT

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

 

1pragma solidity >=0.6.0 <0.8.0;

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

 

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

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

 

1interface IERC20 {

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

 

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

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

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

 

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

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

 

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

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

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

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

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

 

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

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

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

 

1 /**
2 * @dev 将 `spender` 对调用者代币的授权额度设置为 `amount`。
3 *
4 * 返回一个布尔值,表示操作是否成功。
5 *
6 * 重要提示:请注意,使用此方法更改授权额度会带来风险,
7 * 有人可能因不利的交易排序而同时使用新旧两种授权额度。
8 * 缓解此竞态条件的一个可行方案是,先将花费者的授权额度降至 0,
9 * 然后再设置所需的值:
10 * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
11 *
12 * 触发一个 {Approval} 事件。
13 */
14 function approve(address spender, uint256 amount) external returns (bool);
显示全部

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

 

1 /**
2 * @dev 使用
3 * 授权机制将 `amount` 数量的代币从 `sender` 转移到 `recipient`。然后从调用者的授权额度中扣除 `amount`。
4 *
5 * 返回一个布尔值,表示操作是否成功。
6 *
7 * 触发一个 {Transfer} 事件。
8 */
9 function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
显示全部

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

 

1
2 /**
3 * @dev 当 `value` 数量的代币从一个帐户 (`from`) 转移到
4 * 另一个 (`to`) 时触发。
5 *
6 * 请注意,`value` 可能为零。
7 */
8 event Transfer(address indexed from, address indexed to, uint256 value);
9
10 /**
11 * @dev 当通过
12 * 调用 {approve} 为 `owner` 设置 `spender` 的授权额度时触发。`value` 是新的授权额度。
13 */
14 event Approval(address indexed owner, address indexed spender, uint256 value);
15}
显示全部

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

实际合约

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

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

 

Import 语句

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

1
2import "../../GSN/Context.sol";
3import "./IERC20.sol";
4import "../../math/SafeMath.sol";
  • GSN/Context.sol 是使用 OpenGSNopens 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 是为了与 旧版编译器向后兼容。

 

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

1/**
2 * @dev {IERC20} 接口的实现。
3 *
4 * 此实现与代币的创建方式无关。这意味着
5 * 必须使用 {_mint} 在派生合约中添加供应机制。
6 * 有关通用机制,请参阅 {ERC20PresetMinterPauser}。
7 *
8 * 提示:有关详细的说明,请参阅我们的指南
9 * https://forum.zeppelin.solutions/t/how-to-implement-erc20-supply-mechanisms/226[如何
10 * 实现供应机制]。
11 *
12 * 我们遵循了通用的 OpenZeppelin 指南:函数在失败时会还原,而
13 * 不会返回 `false`。但是,此行为是常规行为,
14 * 并且与 ERC20 应用程序的预期不冲突。
15 *
16 * 此外,在调用 {transferFrom} 时会触发 {Approval} 事件。
17 * 这允许应用程序仅通过监听所述事件
18 * 即可重建所有账户的授权额度。EIP 的其他实现
19 * 可能不会触发这些事件,因为规范没有要求。
20 *
21 * 最后,添加了非标准的 {decreaseAllowance} 和 {increaseAllowance}
22 * 函数,以缓解围绕设置
23 * 授权额度的众所周知的问题。请参阅 {IERC20-approve}。
24 */
25
显示全部

合约定义

1contract ERC20 is Context, IERC20 {

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

 

1
2 using SafeMath for uint256;
3

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

变量定义

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

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

1 mapping (address => uint256) private _balances;

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

 

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

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

 

1 uint256 private _totalSupply;

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

 

1 string private _name;
2 string private _symbol;
3 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

构造函数

1 /**
2 * @dev 设置 {name} 和 {symbol} 的值,用
3 * 默认值 18 初始化 {decimals}。
4 *
5 * 要为 {decimals} 选择不同的值,请使用 {_setupDecimals}。
6 *
7 * 所有这三个值都是不可变的:它们只能在
8 * 构造期间设置一次。
9 */
10 constructor (string memory name_, string memory symbol_) public {
11 // In Solidity ≥0.7.0, 'public' is implicit and can be omitted.
12
13 _name = name_;
14 _symbol = symbol_;
15 _decimals = 18;
16 }
显示全部

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

用户界面函数

1 /**
2 * @dev 返回代币的名称。
3 */
4 function name() public view returns (string memory) {
5 return _name;
6 }
7
8 /**
9 * @dev 返回代币的符号,通常是
10 * 名称的缩写。
11 */
12 function symbol() public view returns (string memory) {
13 return _symbol;
14 }
15
16 /**
17 * @dev 返回用于获取其用户表示的小数位数。
18 * 例如,如果 `decimals` 等于 `2`,`505` 个代币的余额应
19 * 向用户显示为 `5,05` (`505 / 10 ** 2`)。
20 *
21 * 代币通常选择值 18,模仿
22 * 以太币和 wei 之间的关系。这是 {ERC20} 使用的值,除非
23 * 调用 {_setupDecimals}。
24 *
25 * 注意:此信息仅用于_显示_目的:它
26 * 绝不影响合约的任何算术,包括
27 * {IERC20-balanceOf} 和 {IERC20-transfer}。
28 */
29 function decimals() public view returns (uint8) {
30 return _decimals;
31 }
显示全部

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

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

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

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

读取代币信息

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

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

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

 

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

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

转移代币

1 /**
2 * @dev 请参阅 {IERC20-transfer}。
3 *
4 * 要求:
5 *
6 * - `recipient` 不能是零地址。
7 * - 调用者必须拥有至少 `amount` 的余额。
8 */
9 function transfer(address recipient, uint256 amount) public virtual override returns (bool) {
显示全部

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

 

1 _transfer(_msgSender(), recipient, amount);
2 return true;
3 }

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

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

授权额度函数

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

allowance 函数

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

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

approve 函数

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

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

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

 

1 _approve(_msgSender(), spender, amount);
2 return true;
3 }

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

transferFrom 函数

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

1 /**
2 * @dev 请参阅 {IERC20-transferFrom}。
3 *
4 * 触发一个 {Approval} 事件,指示更新后的授权额度。EIP
5 * 并未要求此项。请参阅 {ERC20} 开头的注释。
6 *
7 * 要求:
8 *
9 * - `sender` 和 `recipient` 不能是零地址。
10 * - `sender` 必须拥有至少 `amount` 的余额。
11 * - 调用者必须拥有至少 `amount` 的 ``sender`` 代币授权额度
12 * 。
13 */
14 function transferFrom(address sender, address recipient, uint256 amount) public virtual
15 override returns (bool) {
16 _transfer(sender, recipient, amount);
显示全部

 

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

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

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
1 /**
2 * @dev 以原子方式增加调用者授予 `spender` 的授权额度。
3 *
4 * 这是 {approve} 的替代方案,可用于缓解
5 * {IERC20-approve} 中描述的问题。
6 *
7 * 触发一个 {Approval} 事件,指示更新后的授权额度。
8 *
9 * 要求:
10 *
11 * - `spender` 不能是零地址。
12 */
13 function increaseAllowance(address spender, uint256 addedValue) public virtual returns (bool) {
14 _approve(_msgSender(), spender, _allowances[_msgSender()][spender].add(addedValue));
15 return true;
16 }
显示全部

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

1
2 /**
3 * @dev 以原子方式减少调用者授予 `spender` 的授权额度。
4 *
5 * 这是 {approve} 的替代方案,可用于缓解
6 * {IERC20-approve} 中描述的问题。
7 *
8 * 触发一个 {Approval} 事件,指示更新后的授权额度。
9 *
10 * 要求:
11 *
12 * - `spender` 不能是零地址。
13 * - `spender` 必须拥有至少
14 * `subtractedValue` 的调用者授权额度。
15 */
16 function decreaseAllowance(address spender, uint256 subtractedValue) public virtual returns (bool) {
17 _approve(_msgSender(), spender, _allowances[_msgSender()][spender].sub(subtractedValue,
18 "ERC20: decreased allowance below zero"));
19 return true;
20 }
显示全部

修改代币信息的函数

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

_transfer 函数

1 /**
2 * @dev 将 `amount` 数量的代币从 `sender` 转移到 `recipient`。
3 *
4 * 这个内部函数等同于 {transfer},可用于
5 * 例如,实现自动代币费用、惩罚机制等。
6 *
7 * 触发一个 {Transfer} 事件。
8 *
9 * 要求:
10 *
11 * - `sender` 不能是零地址。
12 * - `recipient` 不能是零地址。
13 * - `sender` 必须拥有至少 `amount` 的余额。
14 */
15 function _transfer(address sender, address recipient, uint256 amount) internal virtual {
显示全部

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

 

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

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

 

1 _beforeTokenTransfer(sender, recipient, amount);
2

使用该合约有两种方法:

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

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

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

 

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

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

 

1 emit Transfer(sender, recipient, amount);
2 }

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

_mint 和 _burn 函数

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

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

1 /** @dev 创建 `amount` 数量的代币并将其分配给 `account`,增加
2 * 总供应量。
3 *
4 * 触发一个 {Transfer} 事件,其中 `from` 设置为零地址。
5 *
6 * 要求:
7 *
8 * - `to` 不能是零地址。
9 */
10 function _mint(address account, uint256 amount) internal virtual {
11 require(account != address(0), "ERC20: mint to the zero address");
12 _beforeTokenTransfer(address(0), account, amount);
13 _totalSupply = _totalSupply.add(amount);
14 _balances[account] = _balances[account].add(amount);
15 emit Transfer(address(0), account, amount);
16 }
显示全部

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

 

1 /**
2 * @dev 从 `account` 销毁 `amount` 数量的代币,减少
3 * 总供应量。
4 *
5 * 触发一个 {Transfer} 事件,其中 `to` 设置为零地址。
6 *
7 * 要求:
8 *
9 * - `account` 不能是零地址。
10 * - `account` 必须拥有至少 `amount` 的代币。
11 */
12 function _burn(address account, uint256 amount) internal virtual {
13 require(account != address(0), "ERC20: burn from the zero address");
14
15 _beforeTokenTransfer(account, address(0), amount);
16
17 _balances[account] = _balances[account].sub(amount, "ERC20: burn amount exceeds balance");
18 _totalSupply = _totalSupply.sub(amount);
19 emit Transfer(account, address(0), amount);
20 }
显示全部

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

_approve 函数

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

1 /**
2 * @dev 将 `spender` 对 `owner` 代币的授权额度设置为 `amount`。
3 *
4 * 此内部函数等同于 `approve`,可用于
5 * 例如,为某些子系统设置自动授权额度等。
6 *
7 * 触发一个 {Approval} 事件。
8 *
9 * 要求:
10 *
11 * - `owner` 不能是零地址。
12 * - `spender` 不能是零地址。
13 */
14 function _approve(address owner, address spender, uint256 amount) internal virtual {
15 require(owner != address(0), "ERC20: approve from the zero address");
16 require(spender != address(0), "ERC20: approve to the zero address");
17
18 _allowances[owner][spender] = amount;
显示全部

 

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

1 emit Approval(owner, spender, amount);
2 }
3

修改 Decimals 变量

1
2
3 /**
4 * @dev 将 {decimals} 设置为不同于默认值 18 的值。
5 *
6 * 警告:此函数应仅从构造函数中调用。大多数
7 * 与代币合约交互的应用程序不希望
8 * {decimals} 发生变化,如果发生变化可能会导致工作不正常。
9 */
10 function _setupDecimals(uint8 decimals_) internal {
11 _decimals = decimals_;
12 }
显示全部

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

钩子

1
2 /**
3 * @dev 在任何代币转移之前调用的钩子函数。这包括
4 * 铸造和销毁。
5 *
6 * 调用条件:
7 *
8 * - 当 `from` 和 `to` 都非零时,`amount` 数量的 ``from`` 代币
9 * 将被转移到 `to`。
10 * - 当 `from` 为零时,将为 `to` 铸造 `amount` 数量的代币。
11 * - 当 `to` 为零时,将销毁 `amount` 数量的 ``from`` 代币。
12 * - `from` 和 `to` 永远不会都为零。
13 *
14 * 要了解有关钩子函数的更多信息,请转到 xref:ROOT:extending-contracts.adoc#using-hooks[使用钩子函数]。
15 */
16 function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual { }
17}
显示全部

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

结论

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

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

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

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

页面最后更新: 2025年10月22日

本教程对你有帮助吗?