跳转到主要内容

ERC-20 合约详解

Solidity
erc-20
初级
奥里·波梅兰茨
2021年3月9日
37 分钟阅读

简介

以太坊最常见的用途之一是让一个群体创建可交易的代币,在某种意义上就是他们自己的货币。这些代币通常遵循一个标准,即 ERC-20。该标准使得编写能够与所有 ERC-20 代币配合使用的工具(如流动性池和钱包)成为可能。在本文中,我们将分析 欧本齐柏林 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)。任何需要使用代币合约的代码都可以使用接口中相同的定义,并与所有使用该接口的代币合约兼容,无论是像梅塔马斯克这样的钱包、像 etherscan.io 这样的去中心化应用 (dapp),还是像流动性池这样的其他合约。

Illustration of the ERC-20 interface

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

这是欧本齐柏林提供的 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中定义的ERC-20标准接口。
 */

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

 

interface IERC20 {

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

 

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

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

注意: 理论上,合约的创建者似乎可以通过返回比实际值更小的总供应量来作弊,使每个代币看起来比实际更有价值。然而,这种担忧忽略了区块链的真实本质。区块链上发生的一切都可以被每个节点验证。为了实现这一点,每个合约的机器语言代码和存储在每个节点上都是可用的。虽然你不必发布合约的 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。 当用户调用此函数时,它会创建一笔交易并消耗 Gas。它还会触发一个事件 Transfer,以通知区块链上的所有人该事件的发生。

该函数为两种不同类型的调用者提供两种类型的输出:

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

其他改变合约状态的函数也会产生相同类型的输出。

 

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

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

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

 

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

 

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

最后,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 "../../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 {

此行指定了继承,在本例中继承自上面的 IERC20 和用于 OpenGSN 的 Context

 


    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 等于 1 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,意味着返回一个存储在内存中的字符串。变量(如字符串)可以存储在三个位置:

生命周期合约访问权限Gas 成本
内存函数调用读/写几十或几百(位置越高成本越高)
调用数据函数调用只读不能用作返回类型,只能用作函数参数类型
存储直到被更改读/写高(读取 800,写入 20k)

在这种情况下,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。此外,欧本齐柏林实现超越了基本标准,包含了一些提高安全性的功能: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;
    }

欧本齐柏林安全附加功能

将非零授权额度设置为另一个非零值是危险的,因为你只能控制自己交易的顺序,而不能控制其他任何人的交易顺序。想象一下你有两个用户,天真的 Alice 和不诚实的 Bill。Alice 想要 Bill 提供一些服务,她认为这需要五个代币——所以她给了 Bill 五个代币的授权额度。

然后情况发生了变化,Bill 的价格上涨到了十个代币。仍然想要该服务的 Alice 发送了一笔交易,将 Bill 的授权额度设置为十。当 Bill 在交易池中看到这笔新交易时,他发送了一笔花费 Alice 五个代币的交易,并设置了更高的 Gas 价格,以便它能更快地被打包。这样,Bill 可以先花费五个代币,然后一旦 Alice 的新授权额度被打包,再花费十个代币,总共十五个代币,超过了 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),并仅覆盖你需要修改的那些函数

第二种方法要好得多,因为欧本齐柏林 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 中看到的那样)。这是因为在状态更改期间,你处于不一致的状态。例如,在从发送者的余额中扣除和添加到接收者的余额之间,存在的代币少于应有的数量。如果它们之间有操作,特别是调用不同的合约,这可能会被滥用。

既然你已经了解了欧本齐柏林 ERC-20 合约是如何编写的,特别是它是如何变得更安全的,那就去编写你自己的安全合约和应用程序吧。

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