Ethereum Token

Table of Contents

1. ERC 20

ERC 20 中规定了所有满足该标准的合约必须实现的一些方法和事件,它使得 Token 的发行变得非常容易。

1.1. ERC 20 中的方法和事件

ERC 20 规定必须实现下面 6 个方法:

function totalSupply() public view returns (uint256);  // 返回总共的发行量
function balanceOf(address tokenOwner) public view returns (uint256);  // 返回某帐户的余额
function transfer(address receiver, uint256 tokens) public returns (bool);  // 向另外一个地址转账
function approve(address delegate, uint256 tokens)  public returns (bool);
function transferFrom(address owner, address buyer, uint256 tokens) public returns (bool);
function allowance(address owner, address delegate) public view returns (uint256);

其中,前三个函数比较好理解, totalSupply() 返回总共的发行量, balanceOf() 返回某帐户的余额, transfer() 向另外一个地址转账。

后面三个函数可以认为和“市场运营”行为相关。考虑这个场景:用户 A,手里有 100 万 Token,他可以授权用户 B(可能是个智能合约)最多把他帐户上的 20 万 Token 转给 20 个用户(如用户 C1 到 C20)。这个场景可以通过下面步骤实现:
第一步,用户 A 通过 approve 授权用户 B 指定数量的 Token 转移权,即调用:

approve(用户B的地址, 20万)                    // 用户 A 调用

第二步,用户 B 调用 transferFrom 分发用户 A 的 Token 给用户 C1 到 C20,即调用:

transferFrom(用户A的地址, 用户C1的地址, 1万)   // 用户 B 调用,Token 从 A 转移到 C1
transferFrom(用户A的地址, 用户C2的地址, 1万)   // 用户 B 调用,Token 从 A 转移到 C2
......
transferFrom(用户A的地址, 用户C20的地址, 1万)  // 用户 B 调用,Token 从 A 转移到 C20

此外,通过 allowance 可以查询剩余的授权额度。如,查看用户 A 授权给用户 B 的额度还有多少没有用完:

allowance(用户A的地址, 用户B的地址)            // 所有人都可以调用

除了上面介绍的 6 个必须实现的方法外,ERC 20 中还定义了下面 3 个“可选实现的”方法(不过,大家基本上都实现了这些方法):

function name() public view returns (string)    // 返回 Token 名字,如 MyToken
function symbol() public view returns (string)  // 返回 Token 符号,如 HIX
function decimals() public view returns (uint8) // 返回 Token 精度(小数点位数)

ERC 20 中定义了两类事件日志:

event Transfer(address indexed from, address indexed to, uint256 tokens);   // 有 token 转移时
event Approval(address indexed tokenOwner, address indexed spender, uint256 tokens);

参考:How to Create an ERC20 Token the Simple Way

1.2. Gasless Approval

1.2.1. Permit(EIP 2612)

用户调用 ERC 20 合约的 approve 函数可授权别人转移自己的 ERC 20 代币,但用户需要为这个操作支付 Gas。

EIP 2612 中提出了一种“免 Gas 的授权机制”, 它要求 ERC 20 合约中实现下面 3 个函数:

function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external
function nonces(address owner) external view returns (uint)
function DOMAIN_SEPARATOR() external view returns (bytes32)

如果用户 A 想授权用户 B 转移他的 ERC 20 代币,则用户 A 对下面结构的数据(EIP 712 结构化数据):

{
  "types": {
    "EIP712Domain": [
      {
        "name": "name",
        "type": "string"
      },
      {
        "name": "version",
        "type": "string"
      },
      {
        "name": "chainId",
        "type": "uint256"
      },
      {
        "name": "verifyingContract",
        "type": "address"
      }
    ],
    "Permit": [{
      "name": "owner",
      "type": "address"
      },
      {
        "name": "spender",
        "type": "address"
      },
      {
        "name": "value",
        "type": "uint256"
      },
      {
        "name": "nonce",
        "type": "uint256"
      },
      {
        "name": "deadline",
        "type": "uint256"
      }
    ],
    "primaryType": "Permit",
    "domain": {
      "name": erc20name,
      "version": version,
      "chainId": chainid,
      "verifyingContract": tokenAddress
  },
  "message": {
    "owner": owner,
    "spender": spender,
    "value": value,
    "nonce": nonce,
    "deadline": deadline
  }
}}

进行签名后(设置待签名数据中 owner 为用户 A 地址,spender 为用户 B 地址),得到 v/r/s。

这样其他人拿到 v/r/s 后,调用 permit 函数即可完成用户 A 的授权(因为 permit 函数的内部会调用 approve 函数)。谁调用 permit 合约函数谁支付 Gas,这样就不需要由用户 A 来支付 Gas 了,这就是所谓的 Gasless Approval(注:并没有真正免 Gas,只是 Gas 可不由用户 A 来支付)。

EIP 2612 实现可参考:https://github.com/soliditylabs/ERC20-Permit/blob/main/contracts/ERC20Permit.sol

1.2.2. Permit2

前一节介绍的 Permit(EIP 2612)需要在 ERC 20 代币中增加 3 个函数,所以它只能应用于新部署的 ERC 20 代币中,无法应用于已经部署过的 ERC 20 代币中。

Permit2 是另一种授权方案,它可以应用于已经部署过的 ERC 20 代币中,适用性更好。

在介绍 Permit2 之前,先介绍一下 ERC 20 代币授权 Protocol Contract 转移他代币的标准做法,如图 1 所示。

eth_token_transferFrom.png

Figure 1: Standard Allowance Model

Permit2 的原理如图 2 所示。

eth_token_permit2.png

Figure 2: Permit2

假设 Alice 想授权 Protocol Contract 转移他的 ERC 20 代币,则:

  1. Alice 调用 ERC 20 的 approve 函数授权 Permit2 合约(并不是直接授权给 Protocol Contract)转移他的 ERC 20 代币;
  2. Alice 生成 Permit2 签名数据,在和 Protocol Contract 交互时,附上 Permit2 签名数据,Protocol Contract 需要转移 Alice ERC 20 代币时调用 Permit2 合约的 permitTransferFrom 函数即可;而 Permit2 合约的 permitTransferFrom 函数会去调用 ERC 20 的 transferFrom 函数。

需要说明的是:

  1. Permit2 方案中,Alice 需要调用一次 approve 函数以授权给 Permit2 合约,这次调用的 Gas 一定是由 Alice 支付的。不过,这个操作只需要执行一次,就算我们需要授权不同的 Protocol Contract 转移他的 ERC 20 代币,也只需要一次授权给 Permit2 合约。
  2. Permit2 方案需要 Protocol Contract 做适配。在标准做法中,Protocol Contract 直接调用 ERC 20 的 transferFrom 函数即可;而 Permit2 方案要求 Protocol Contract 调用 Permit2 合约的 permitTransferFrom 函数。

参考:
Permit2 的介绍:https://github.com/dragonfly-xyz/useful-solidity-patterns/tree/main/patterns/permit2
Uniswap 所使用的 Permit2 地址:https://etherscan.io/address/0x000000000022d473030f116ddee9f6b43ac78ba3

1.3. ERC 20 的实现

下面是 ERC 20 的一个简单实现(摘自:https://www.toptal.com/ethereum/create-erc20-token-tutorial ):

pragma solidity >=0.5.0;

library SafeMath {
    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        assert(b <= a);
        return a - b;
    }

    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        assert(c >= a);
        return c;
    }
}

contract ERC20Basic {
    string public constant name = "ERC20Basic";
    string public constant symbol = "BSC";
    uint8 public constant decimals = 18;

    event Approval(
        address indexed tokenOwner,
        address indexed spender,
        uint256 tokens
    );
    event Transfer(address indexed from, address indexed to, uint256 tokens);

    mapping(address => uint256) balances;   // 代币余额就是 mapping 数据 balances 中的一个数字

    mapping(address => mapping(address => uint256)) allowed;

    uint256 totalSupply_;

    using SafeMath for uint256;

    constructor(uint256 total) public {
        totalSupply_ = total;
        balances[msg.sender] = totalSupply_;
    }

    function totalSupply() public view returns (uint256) {
        return totalSupply_;
    }

    function balanceOf(address tokenOwner) public view returns (uint256) {
        return balances[tokenOwner];
    }

    function transfer(address receiver, uint256 numTokens)
        public
        returns (bool)
    {
        require(numTokens <= balances[msg.sender], "balance not enough");
        balances[msg.sender] = balances[msg.sender].sub(numTokens);
        balances[receiver] = balances[receiver].add(numTokens);
        emit Transfer(msg.sender, receiver, numTokens);
        return true;
    }

    function approve(address delegate, uint256 numTokens)
        public
        returns (bool)
    {
        allowed[msg.sender][delegate] = numTokens;
        emit Approval(msg.sender, delegate, numTokens);
        return true;
    }

    function allowance(address owner, address delegate)
        public
        view
        returns (uint256)
    {
        return allowed[owner][delegate];
    }

    function transferFrom(address owner, address buyer, uint256 numTokens)
        public
        returns (bool)
    {
        require(numTokens <= balances[owner], "balance not enough");
        require(numTokens <= allowed[owner][msg.sender], "allowance not enough");

        balances[owner] = balances[owner].sub(numTokens);
        allowed[owner][msg.sender] = allowed[owner][msg.sender].sub(numTokens);
        balances[buyer] = balances[buyer].add(numTokens);
        emit Transfer(owner, buyer, numTokens);
        return true;
    }
}

下面是 ERC 20 的其它实现:
ConsenSys implementation
OpenZeppelin implementation

1.3.1. 错误的 ERC 20 实现(主链上的合约漏洞)

如果实现合约时程序员大意了,那么可能引入安全漏洞。

以太坊主链上 BEC 合约存在整数溢出漏洞。下面是 BEC 合约有问题的代码片断:

  function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
    uint cnt = _receivers.length;
    uint256 amount = uint256(cnt) * _value;   // 这里可能整数溢出
    require(cnt > 0 && cnt <= 20);
    require(_value > 0 && balances[msg.sender] >= amount);

    balances[msg.sender] = balances[msg.sender].sub(amount);
    for (uint i = 0; i < cnt; i++) {
        balances[_receivers[i]] = balances[_receivers[i]].add(_value);
        Transfer(msg.sender, _receivers[i], _value);
    }
    return true;
  }

上面代码摘自:https://etherscan.io/address/0xc5d105e63711398af9bbff092d4b6769c82f793d#code
BEC 漏洞的详细分析可参考:http://finance.sina.com.cn/blockchain/coin/2018-04-25/doc-ifzqvvsc0353302.shtml
BEC 有 holder 的持币比例超过了 100%:https://etherscan.io/token/0xc5d105e63711398af9bbff092d4b6769c82f793d#balances
这样的漏洞合约还有很多,如 SMT 也有 holder 的持币比例超过了 100%:https://etherscan.io/token/0x55f93985431fc9304077687a35a1ba103dc1e081#balances

2. ERC 223

在讨论 ERC 223 前,我们先关注 ERC 20 的一个缺点。

用户 U1 转账(即调用 transfer )100 个 ERC 20 的代币 T1 给用户 U2,操作完成后在代币合约 T1 中会把 U1 的余额减少 100,U2 的余额增加 100。用户 U2 再调用 transfer ,还可以把收到的代币转给别人。

但如果用户 U1 转账 100 个代币 T1 给一个“合约地址 C1”呢?代币合约 T1 中会把 U1 的余额减少 100,而 C1 的余额增加 100。问题在于,合约 C1 很可能无法使用(即转出)这个代币 T1。合约要转出代币 T1 就需要调用它的 transfer 方法。合约 C1 中很可能根本没有调用 T1 合约 transfer 方法的代码,当然我可以考虑周到,在实现 C1 时预留一个函数,具备转出代币 T1(或者任意其它代币)的功能,这样转给合约 C1 的代币还能被合约 C1 接着使用。但是,我们不能要求所有的合约都实现这样的函数,一旦有人往这样的合约转入代币,那么这部分转入的代币就“锁死”(转出的人减少了,收到代币的合约却用不了它)了。

总结:ERC 20 存在代币“锁死”的风险,以太网上有很多这种丢失代币的例子,参见:https://github.com/ethereum/eips/issues/223

ERC 223 主要是为了解决“往合约地址转 ERC 20 代币可能导致代币被锁死”而提出的。

ERC 223 中规定,往合约地址转代币时,必须调用目标合约的 tokenFallback 方法;此外 transfer 方法还多了一个参数,以附带转账时的其它信息。 下面是 ERC 223 中 transfer 方法的一个参考实现:

// from https://github.com/Dexaran/ERC223-token-standard/blob/development/token/ERC223/ERC223.sol

    /**
     * @dev Transfer the specified amount of tokens to the specified address.
     *      Invokes the `tokenFallback` function if the recipient is a contract.
     *      The token transfer fails if the recipient is a contract
     *      but does not implement the `tokenFallback` function
     *      or the fallback function to receive funds.
     *
     * @param _to    Receiver address.
     * @param _value Amount of tokens that will be transferred.
     * @param _data  Transaction metadata.
     */
    function transfer(address _to, uint _value, bytes memory _data) public returns (bool success){
        // Standard function transfer similar to ERC20 transfer with no _data .
        // Added due to backwards compatibility reasons .
        balances[msg.sender] = balances[msg.sender].sub(_value);
        balances[_to] = balances[_to].add(_value);
        if(Address.isContract(_to)) {                           // 关注这行和下面两行
            IERC223Recipient receiver = IERC223Recipient(_to);
            receiver.tokenFallback(msg.sender, _value, _data);
        }
        emit Transfer(msg.sender, _to, _value, _data);
        return true;
    }

所有没有实现 tokenFallback 的合约,都无法接收 ERC 223 代币。 因为,转账时找不到目标合约的 tokenFallback 方法,这行调用会报错(假设也没的指定 fallback 方法),从而转账失败,这样代币还在转账发起者的账户中,不会“锁死”。

当然,要保证代币不“锁死”在目标合约中,目标合约还应该正确地实现 tokenFallback

3. ERC 677(增加 transferAndCall)

ERC 677 标准是 ERC 20 的一个扩展。ERC 677 除了包含了 ERC 20 的所有方法和事件之外,增加了一个 transferAndCall 方法:

function transferAndCall(address receiver, uint amount, bytes data) returns (bool success)

这个方法除了完成转账外,还会调用接收合约的 onTokenTransfer 方法,用来触发接收合约的逻辑。 onTokenTransfer 方法定义如下:

function onTokenTransfer(address from, uint256 amount, bytes data) returns (bool success)

接收合约就可以在这个方法中定义自己的业务逻辑,可以在发生转账的时候自动触发。

ChainLink Token (LINK) 就使用了 ERC 677,参见:https://etherscan.io/address/0x514910771af9ca656af840dff83e8264ecf986ca

3.1. ERC 677 vs. ERC 223

ERC 223 通过 transfer 往合约转账时,会调用目标合约的 tokenFallback 方法;而 ERC 677 通过 transferAndCall 往合约转账时,会调用目标合约的 onTokenTransfer 方法。

这两种方式都可以实现“转账时自动触发目标合约的业务逻辑”。但 ERC 223 直接修改了 ERC 20 中 transfer 方法的语义,这导致一个问题:没有实现 tokenFallback 方法的合约无法接收 ERC 233 代币,从某种意义上说这限制了 ERC 233 的应用;而 ERC 677 是通过增加一个新方法( transferAndCall )的方式来实现“触发目标合约的业务逻辑”,如果不需要这个功能时,我们还是可以使用 ERC 20 中就存在的 transfer 方法往任意合约转移 ERC 677 代币,这使得 ERC 677 更为灵活。

4. ERC 777

ERC 777 和 ERC 20 兼容,并对其进行了一些增强。

在介绍 ERC 777 前,先介绍一下 ERC 1820。ERC 1820 是一个“注册表合约”,提供了两个主要接口:

setInterfaceImplementer(address _addr, bytes32 _interfaceHash, address _implementer)
getInterfaceImplementer(address _addr, bytes32 _interfaceHash) external view returns (address)

setInterfaceImplementer 用来设置地址(参数 _addr )的接口(keccak256 哈希)由指定的合约(参数 _implementer )实现。
getInterfaceImplementer 用来查询地址(参数 _addr )的接口由哪个合约实现。

ERC 1820 是一个全局的合约,它是通过非常巧妙的方式部署在以太坊主链及测试链上,且其地址总是 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24 (如果你是自己在本地搭建测试链,则可能没有提前部署该合约)。

通过 ERC 777 的 transfer 方法(ERC 20 中有同名方法)进行转账时,会检测目标地址(包含普通地址和合约地址)是否在 ERC 1820 合约上注册了接口 ERC777TokensRecipient 的实现,如果目标地址注册了 ERC777TokensRecipient 的实现,则调用其 tokensReceived 方法。

比如,ERC 777 转账给地址 A(它可以是普通地址,也就是合约地址),则会先调用 ERC 1820 合约的 getInterfaceImplementer(A, keccak256(ERC777TokensRecipient)) 方法,如果它返回了地址 B,那么会调用合约 B 的 tokensReceived 方法。如图 3(摘自:https://tokenmint.io/blog/what-is-erc-777-token-and-how-it-differs-from-erc20.html )所示。

eth_token_ERC777_hooks.png

Figure 3: ERC777 hooks

3 中仅演示了一个 Hook,其实还有另外一个 Hook,即 keccak256("ERC777TokensSender") 。前面例子中,如果在注册表合约 ERC 1820 中注册了地址 A 的接口 keccak256("ERC777TokensSender") 的实现合约地址,那么从 A 转出代币时会调用实现合约地址中的方法 tokensToSend

参考:ERC 777 功能型代币(通证)最佳实践

4.1. ERC 777 的实现

ERC 777 的标准比较长,如果从零开始实现比较麻烦。OpenZeppelin 有 ERC 777 的开源实现,请参考:https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC777/ERC777.sol

基于 OpenZeppelin 的实现我们可以很简单地实现自己的 ERC 777 代币合约,如:

pragma solidity ^0.6.0;

import "@openzeppelin/contracts/token/ERC777/ERC777.sol";

contract MyERC777 is ERC777 {
    // name 为 MyERC777Token,symbol 为 XXX
    constructor() ERC777("MyERC777Token", "XXX", new address[](0)) public
    {
        // 初始发行量 2100 万
        uint256 initialSupply = 2100 * 10000 * 10 ** 18;   // ERC777 的 decimals 固定为 18
        _mint(msg.sender, msg.sender, initialSupply, "", "");
    }
}

4.2. 实现接收 ERC 777 代币的合约

下面是接收 ERC 777 代币的合约的例子(仅演示,未亲自测试):

pragma solidity ^0.6.0;

import "@openzeppelin/contracts/token/ERC777/IERC777Recipient.sol";
import "@openzeppelin/contracts/token/ERC777/IERC777.sol";
import "@openzeppelin/contracts/introspection/IERC1820Registry.sol";

contract Simple777Recipient is IERC777Recipient {
    address _owner;
    IERC777 _token;

    IERC1820Registry private _erc1820 = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);

    event DoneStuff(address operator, address from, address to, uint256 amount, bytes userData, bytes operatorData);

    // keccak256("ERC777TokensRecipient")
    bytes32 private constant TOKENS_RECIPIENT_INTERFACE_HASH = 0xb281fc8c12954d22544db45de3159a39272895b169a852b314f9cc762e44c53b;

    constructor(address token) public {
        _owner = msg.sender;
        _token = IERC777(token);

        // 向 ERC 1820 中注册接口 ERC777TokensRecipient 的实现为“本合约”
        _erc1820.setInterfaceImplementer(address(this), TOKENS_RECIPIENT_INTERFACE_HASH, address(this));
    }

    // 收到 ERC 777 代币时会被自动调用
    function tokensReceived(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata userData,
        bytes calldata operatorData
    ) external override {
        // 只要 constructor 函数中指定的 ERC 777 代币,其它的 ERC 777 代币直接拒绝
        require(msg.sender == address(_token), "Simple777Recipient: Invalid token");

        // 记录 Log
        emit DoneStuff(operator, from, to, amount, userData, operatorData);
    }

    // 把收到的代币提到“本合约”创建者帐户
    function withdrawAll() external {
        require(msg.sender == _owner, "no permision");
        uint256 balance = _token.balanceOf(address(this));
        _token.send(_owner, balance, "withdrawAll"); // 也可以调 transfer,这时不能附带信息
    }

}

4.3. send/mint 到合约有其限制

通过 ERC 777 的 send/mint 方法进行转账时,当目标地址是合约地址时,目标地址必须在 ERC 1820 合约上注册了接口 ERC777TokensRecipient 的实现,否则转换会失败。

这限制了很多合约地址无法接收 send/mint 发来的 ERC 777 代币,因为合约很可能没有在 ERC 1820 上注册接口 ERC777TokensRecipient 的实现。对于 send 来说,问题不大,因为使用 transfer 代替 send 可绕过这个问题;对于 mint 来说,不好解决,有人抱怨这个问题,参考:https://github.com/OpenZeppelin/openzeppelin-contracts/issues/2226

5. ERC 721(非同质代币)

ERC 721 代币是“非同质代币”(Non-Fungible Token, NFT),每一枚币都有独一无二的编号(TokenID)。

主网上的一些 ERC 721 可参考:https://etherscan.io/tokens-nft

5.1. ERC721 的实现

6. ERC 1155(Manage Multiple Token Types)

ERC 1155 同时借鉴了 ERC 20 和 ERC 721,它使用“一个合约”来同时表达 fungible tokens 和 non-fungible tokens。

6.1. ERC1155 的实现

OpenZeppelin 对 ERC1155 的实现可参考:https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC1155/ERC1155.sol

Enjin 对 ERC1155 的实现可参考:https://github.com/enjin/erc-1155/blob/master/contracts/ERC1155.sol

在 ERC1155 中,每个 id 关联着不同的 owner 和对应的余额,在实现余额时往往采用下面方式:

contract ERC1155
{
    // id => (owner => balance)
    mapping (uint256 => mapping(address => uint256)) internal balances;

    // ......
}

这样,对于某个编号为 id1 的 token,可能被多个人拥有不同的数量,如:

#id  owner                                       balance
id1  0x65c956226C36d48FD6A7F457813e53B79c563720  7100
id1  0x21646043e1ee182576F33c07C03b2d404746B535  2000
id1  0x5714108B16557ce210c50dDbFF7264431E1408D7  1300

Author: cig01

Created: <2019-02-25 Mon>

Last updated: <2020-10-28 Wed>

Creator: Emacs 27.1 (Org mode 9.4)