目前很多项目都使用 Tokens 来充当项目的代币,其实 Tokens 的本质就是合约里的一个变量,而 Tokens 那么火热的原因之一便是有相应的标准,这个标准便是本文要讨论了 ERC-20 标准。
ERC-20 是以太坊上的一个代币协议,我们可以将其理解成一组接口,基于 ERC-20 协议开发的代币便认为是标准化代币,标准化的好处便是兼容性,大家都按这个标准来玩,生态发展也会很好,比如各种以太坊钱包对 ERC-20 代币的支持,以及各种中心化或去中心化交易所对 Tokens 代币的支持。
USDT 是 ERC-20 代币里使用场景非常广泛的稳定币,所谓稳定币,即 1USDT=1 美元,不会有太大的价格波动,背后是美元背书(那美元背后是啥呢?以前是黄金,现在是石油和军事力量)。
本文,我们会先简单介绍 ERC-20 代币,然后再逐行分析 USDT 的合约代码(USDT 在不同链上都有相应的实现,本文讨论的是以太坊链上的 USDT)。
ERC-20 是 ETH 下的用于实现代币的协议,协议的所有细节可以看:https://eips.ethereum.org/EIPS/eip-20
BSC 上有名为 BEP-20 的协议,它是 Bianca 参考 ERC-20 弄出来的具有相似功能的协议,因为定义的接口函数相同,MetaMask 同样可以连上,导致有些朋友将币转错链,从而导致代币的遗失。
通过 eip-20 可知,ERC-20 的标准接口如下:
contract ERC20 {
function name() constant returns (string name)
function symbol() constant returns (string symbol)
function decimals() constant returns (uint8 decimals)
function totalSupply() constant returns (uint totalSupply);
function balanceOf(address _owner) constant returns (uint balance);
function transfer(address _to, uint _value) returns (bool success);
function transferFrom(address _from, address _to, uint _value) returns (bool success);
function approve(address _spender, uint _value) returns (bool success);
function allowance(address _owner, address _spender) constant returns (uint remaining);
event Transfer(address indexed _from, address indexed _to, uint _value);
event Approval(address indexed _owner, address indexed _spender, uint _value);
}
逐个解释一下上述的接口函数:
name (): 返回 string 类型的 ERC-20 代币的名称
symbol (): 返回 string 类型的 ERC-20 代币符号,可以理解为代币的简称
decimals (): 当前代币支持几位小数,如果为 3,则支持 0.001 粒度的代币。在智能合约里,其实是不存在小数的,而是通过 unit256 来存在,deciaml 用于表示 uint256 存储的值中有几位代表小数。
totalSupply (): 代币总发行量
balanceOf (address _owner): _owner 地址中代币的余额
transfer (address _to, uint _value): 转账函数,假设地址 A 调用了 transfer 函数,则表示将地址 A 中_value 个 token 转给地址_to
approve (address _spender, uint _value): 授权函数,允许_spender 地址从自己的账户地址中转移_value 个 token(很多钓鱼网站使用的函数)
transferFrom (address _from, address _to, uint _value): 转账函数,与 approve 函数搭配使用,通过 approve 函数获得_from 地址的授权,授权对象是当前调用 transferFrom 函数的合约地址,此时合约便可以转移_from 地址中的_value 个 token 到_to 地址中
allowance (address _owner, address _spender): 返回_spender 地址还能从_owner 地址中提取多少 token,当合约通过 approve 函数获得某地址授权后,默认是可以转移该地址中所有的 token 的。
event Transfer (address indexed _from, address indexed _to, uint _value): 转账事件,所谓事件,其本质是记录的日志,当转账事件发生时,外界是无法监听相关数据的,即不知道转账发生了,此时的解决方法便是利用事件,将信息记录起来,外界通过监听事件来判断合约做了什么,因为事件本身的作用与目的,发送事件也被称为广播事件。
event Approval (address indexed _owner, address indexed _spender, uint _value): 授权事件
ERC-20 标准中,transfer、approve、transferFrom 这三者是比较容易搞混的函数,这里简单解释一下:
transfer (address _to, uint _value) 是单纯的转账,假设账户 A 调用了 transfer 函数给账户 B 转账,形式为:transfer (B, 100),该函数的会检查 A 地址中是否有 100 个相关代币,如果有,则将 100 个代币转到 B 地址中。
transferFrom (address _from, address _to, uint _value) 是替别人转账,假设账户 A 中有 100 个代币,账户 A 同样想转给账户 B,但他不想直接转,此时便出现了账户 C(通常是一个合约),合约 C 会通过 approve 函数获得操作账户 A 中代币的授权,然后合约 C 通过 transferFrom 函数将账户 A 中的 100 个代币转移给账户 B。
你可能会疑惑,转账为啥要还要引入合约 C 这个第三方?直接用 transfer 函数转账不行吗?
只使用 tranfer 函数进行转账是没有问题的,但并不能满足所有的情况,比如 DeFi 的各种借贷应用,本质还是将账户 A 的代币转给账户 B,但账户 A 之所以会借是因为有借出去的代币可以产生利息收益,这个功能就需要一个独立于账户 A 与账户 B 之外的合约 C 来实现,比如 Uniswap 就会获取你账户中代币的操作权限。
USDT-ETH 合约代码地址为:https://etherscan.io/address/0xdac17f958d2ee523a2206206994597c13d831ec7#code
我们基于这份代码来理解以 USDT 的逻辑以及一个主流代币是怎么使用 ERC-20 标准的。
USDT 的合约代码中,一开始定义了名为 SafeMath 的库,旧文里提过,0.8 版本下的 Solidity 会出现 OverFlow 与 UnderFlow 问题,一个具体的例子:
uint8 number = 255;
number++;
上述代码其实会出现 OverFlow,number 会从 255 变为 0,而不是期望的 256,其背后的原因是:你把 1 加到二进制 11111111,它会重置回 00000000,就像一个时钟从 23:59 到 00:00。
为了避免 OverFlow 与 UnderFlow,常见的解决方法是使用 0.8 以上的 Solidity 或者对各种运算做处理,而 USDT 的合约里,便通过定义 SafeMath 类库来修改合约运算的动作,这里截取 SafeMath 中加法函数实现的代码,如下:
function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
// 断言:如果c大于等于a,则正常,否则断言失败
assert(c >= a);
return c;
}
从上述代码可知,如果出现了 OverFlow,那么 add 函数中的 assert 是会失败的,从而避免 OverFlow 的出现。
这里还涉及一个知识点,那便是 SafeMath 是由 library 关键字定义的,它与 contract 关键字不同,后续文章会讨论其中差异与细节。
library 定义出 SafeMath 后,通过 using...for... 语法使用则可,截取 USDT 中,使用 SafeMath 的相关代码:
contract BasicToken is Ownable, ERC20Basic {
using SafeMath for uint;
接着 SafeMath 类库往下看,会遇到 Ownable 合约,代码量比较小,直接复制出来,然后给出相关的注释,代码如下:
contract Ownable {
// 合约所有者地址
address public owner;
// 构造函数
function Ownable() public {
owner = msg.sender;
}
// 函数修饰器
modifier onlyOwner() {
// 调用函数的地址是否为合约所有者
require(msg.sender == owner);
// 被修饰函数的原逻辑
_;
}
// 转移Ownable合约的所有者
function transferOwnership(address newOwner) public
onlyOwner {
// 不是空地址
if (newOwner != address(0)) {
owner = newOwner;
}
}
}
首先看到 Ownable () 构造函数,合约中,构造函数由两种写法,如下:
// 第一种写法
function Ownable() public {
owner = msg.sender;
}
// 第二种写法
function constructor() public {
owner = msg.sender;
}
构造函数在合约被部署时,自动被 EVM 调用,msg.sender 便是合约的部署者,根据 Ownable 合约的上下文可知,谁部署当前合约,谁就是这个合约的所有者。
接着看到 modifier 修饰器关键字,它用于定义函数修饰器,这里定义了 onlyOwner 修饰函数,其作用是判断调用函数者是否是合约拥有者。
函数修饰器的用法通过 transferOwnership 函数便可知,直接将 onlyOwner 放在函数定义的后面则可,被 onlyOwner 修饰后的 transferOwnership 函数只能被合约所有者调用。
transferOwnership 函数的作用是替换当前合约的所有者。
USDT 中,将 ERC-20 标准通过 ERC20Basic 合约与 ERC20 合约实现,ERC20 合约通过 is 关键字继承于 ERC20Basic 合约,因为在 ERC-20 协议一节已经讨论的比较清晰了,这里就简单展示代码,不再详聊。
contract ERC20Basic {
uint public _totalSupply;
// 代币总量
function totalSupply() public constant returns (uint);
// who地址中代币余额
function balanceOf(address who) public constant returns (uint);
// 转账函数:调用者将value个代币转账给to地址
function transfer(address to, uint value) public;
// 转账事件
event Transfer(address indexed from, address indexed to, uint value);
}
contract ERC20 is ERC20Basic {
// spender地址还可从owner地址中提取多少代币
function allowance(address owner, address spender) public constant returns (uint);
// 转账函数:from地址转value个代币给to地址,当前合约要有操作from地址的授权许可
function transferFrom(address from, address to, uint value) public;
// 授权函数
function approve(address spender, uint value) public;
// 授权事件
event Approval(address indexed owner, address indexed spender, uint value);
}
BasicToken 合约继承于 Ownable 与 ERC20Basic,并在合约中使用 SafeMath,其具体代码如下:
contract BasicToken is Ownable, ERC20Basic {
// 使用SafeMath
using SafeMath for uint;
// 余额,本质是一个mapping
mapping(address => uint) public balances;
// 基点率
uint public basisPointsRate = 0;
// 最大手续费
uint public maximumFee = 0;
// 避免短网址攻击
modifier onlyPayloadSize(uint size) {
require(!(msg.data.length < size + 4));
_;
}
// 重写transfer函数
function transfer(address _to, uint _value) public onlyPayloadSize(2 * 32) {
// 手续费,基于交易代币量(_value)与基点率来计算手续费
uint fee = (_value.mul(basisPointsRate)).div(10000);
if (fee > maximumFee) {
fee = maximumFee;
}
// 扣除手续费后,还需要转账的金额
uint sendAmount = _value.sub(fee);
// 转账交易,本质是调整一下mapping结构中数值
// 发起转账的账户msg.sender减去_value个
balances[msg.sender] = balances[msg.sender].sub(_value);
// 接收转账的账户_to收到sendAmount(扣除手续费)个代币
balances[_to] = balances[_to].add(sendAmount);
// 如果有手续费,那么手续费记录在合约所有者的账户地址中
if (fee > 0) {
balances[owner] = balances[owner].add(fee);
// 广播转账事件
Transfer(msg.sender, owner, fee);
}
// 广播转账事件
Transfer(msg.sender, _to, sendAmount);
}
// 获取_owner地址中的余额
function balanceOf(address _owner) public constant returns (uint balance) {
return balances[_owner];
}
}
上述代码给了逐行注释,理解起来,应该没啥难度。
值得一聊的是,所有的 Tokens 代币转账,本质就是 mapping 数据结构上,不同数值的变化,其实不需要觉得魔幻,你支付宝的金额,说到底也是一个数据结构上数值的变化而已。
此外,BasicToken 合约重写了 transfer 函数,其中包含了手续费的逻辑,但因为基点率为 0,所以手续费相关的逻辑没啥影响(后续的子合约中,会有设置基点率的方法)。
StandardToken 合约继承于 BasicToken 合约与 ERC20 合约,重写了 transferFrom 方法与 approve 方法,其代码如下:
contract StandardToken is BasicToken, ERC20 {
mapping (address => mapping (address => uint)) public allowed;
uint public constant MAX_UINT = 2**256 - 1;
// 重写转账交易
function transferFrom(address _from, address _to, uint _value) public onlyPayloadSize(3 * 32) {
// 合约被授权操作的代币数额
var _allowance = allowed[_from][msg.sender];
// 不需要这个判断了,因为使用了_allowance.sub(_value),如果_allowance小于_value,无法通过断言,则抛出异常
// if (_value > _allowance) throw;
// 手续费
uint fee = (_value.mul(basisPointsRate)).div(10000);
if (fee > maximumFee) {
fee = maximumFee;
}
if (_allowance < MAX_UINT) {
// 可用金额减去_value
// _from: 转账账户地址
// msg.sender: 合约地址
// 如果数额不足,则抛出异常
allowed[_from][msg.sender] = _allowance.sub(_value);
}
// 转账金额
uint sendAmount = _value.sub(fee);
// 转账交易
balances[_from] = balances[_from].sub(_value);
balances[_to] = balances[_to].add(sendAmount);
// 手续费的处理
if (fee > 0) {
balances[owner] = balances[owner].add(fee);
Transfer(_from, owner, fee);
}
Transfer(_from, _to, sendAmount);
}
// 重写授权方法
function approve(address _spender, uint _value) public onlyPayloadSize(2 * 32) {
// 2种情况下,可以授权
// 情况1: _value=0,即授权给合约操作代币量为0,即合约无法操作账户中的代码
// 情况2: 合约可操作的代币数量为0
require(!((_value != 0) && (allowed[msg.sender][_spender] != 0)));
// msg.sender: 转账账户地址
// _spender: 合约地址
allowed[msg.sender][_spender] = _value;
Approval(msg.sender, _spender, _value);
}
// 查看余额,_spender地址还能从_owner地址操作多少代币
function allowance(address _owner, address _spender) public constant returns (uint remaining) {
return allowed[_owner][_spender];
}
}
StandardToken 合约通过 allowed 变量来记录授权数据,它是一个嵌套的 mapping 结构:授权账户地址 -> 合约地址 -> 允许操作的代币金额。
在使用 transferFrom 函数对用户账户代币进行转账操作前,需要通过 approve 函数让合约可以操作自己账户中的代币。
看到 approve 函数中的注释,只有 2 种情况下,用户账户才能授权成功,这两种情况分别是:
情况 1:_value=0,即只授权 0 个代币给合约操作,这样,合约其实无法操作账户中的代币。通过授权 0 个代币的方式,可以将账户之前授权给合约的权限收回。
情况 2:合约可操作的代币数量为 0,即合约对当前账户是没有操作其代币权限的。
为什么要加上这条 require 条件?
主要是因为 ETH 打包交易时,条件竞争的问题,相关的研究可看:[ERC20 API: An Attack Vector on the Approve/TransferFrom Methods],后面单独开文来讨论这个问题,涉及到 ETH 打包流程的背景知识。
过了这个 require 判断,approve 函数的逻辑就很简单的,所谓授权,就是将授权账户地址、合约地址、授权操作代币数量记录在 allowed 变量中。
完成授权后,便可以通过 transferFrom 函数进行交易了,代码逻辑相比于 transfer 函数,多了操作 allowed 变量的逻辑,如果合约可操作的当前账户的代币数小于_value,那么 sub 函数的断言会失败,则 transferFrom 函数交易失败。
此外,这里还有一个坑,BasicToken 合约和 StandardToken 合约重写了 transfer、approve、transferFrom 方法其实并不完全满足 ERC-20 标准,以 transfer 函数为例:
ERC-20 标准中,transfer 函数是需要返回是否转账成功这一 bool 类型结果的,而 BasicToken 合约中实现的 transfer 函数并没有返回值,所以,USDT 在 ETH 上的实现并没严格遵守 ERC-20 标准,approve 函数与 transferFrom 函数的实现也有相同的问题。
BlackList 合约用于实现 USDT 黑名单的功能,其合约代码如下:
contract BlackList is Ownable, BasicToken {
// 判断_maker是否在黑名单中
function getBlackListStatus(address _maker) external constant returns (bool) {
return isBlackListed[_maker];
}
// 获得合约所有者地址
function getOwner() external constant returns (address) {
return owner;
}
// 黑名单
mapping (address => bool) public isBlackListed;
// 将_evilUser添加进黑名单
function addBlackList (address _evilUser) public onlyOwner {
// 将isBlackListed中对应的值设置为true
isBlackListed[_evilUser] = true;
// 广播添加黑名单事件
AddedBlackList(_evilUser);
}
// 将_clearedUser移出黑名单
function removeBlackList (address _clearedUser) public onlyOwner {
isBlackListed[_clearedUser] = false;
RemovedBlackList(_clearedUser);
}
// 销毁黑名单中的代币
function destroyBlackFunds (address _blackListedUser) public onlyOwner {
// 判断账户是否在黑名单中
require(isBlackListed[_blackListedUser]);
// 账户余额
uint dirtyFunds = balanceOf(_blackListedUser);
// 清空余额
balances[_blackListedUser] = 0;
// 销毁相应的代币数,所谓销毁便是减少_totalSupply
_totalSupply -= dirtyFunds;
// 广播销毁代币广播
DestroyedBlackFunds(_blackListedUser, dirtyFunds);
}
event DestroyedBlackFunds(address _blackListedUser, uint _balance);
event AddedBlackList(address _user);
event RemovedBlackList(address _user);
}
BlackList 合约实现的黑名单与传统的黑名单没啥特别大的区别,都是记录一下黑名单,限制其使用,上述代码也是逐行注释,就不再赘述。
Pausable 合约逻辑非常简单,就是对 paused 标识符的操作,其主要的业务情景就是合约在升级时将合约状态修改为暂停状态。
// Pausable合约用于更新合约时,设置暂停状态
contract Pausable is Ownable {
event Pause();
event Unpause();
// 暂停状态,false表示未暂停,true表示暂停
bool public paused = false;
// 未暂停状态
modifier whenNotPaused() {
require(!paused);
_;
}
// 暂停状态
modifier whenPaused() {
require(paused);
_;
}
// 将合约状态改为暂停状态
function pause() onlyOwner whenNotPaused public {
paused = true;
Pause();
}
function unpause() onlyOwner whenPaused public {
paused = false;
Unpause();
}
}
UpgradedStandardToken 合约定义了当前合约升级后要支持的接口,即你升级的,新合约中要实现这些方法,其作用就是兼容旧版本合约的逻辑,其代码如下:
contract UpgradedStandardToken is StandardToken{
// those methods are called by the legacy contract
// and they must ensure msg.sender to be the contract address
function transferByLegacy(address from, address to, uint value) public;
function transferFromByLegacy(address sender, address from, address spender, uint value) public;
function approveByLegacy(address from, address spender, uint value) public;
}
因为这里只定义了接口,所以暂时没啥用,而且 USDT 目前也还没有升级过,所以可以暂时不去理会,当然你感兴趣可以通过 remix,自己部署多个 USDT,感受升级的过程。
前面的所有合约都是为 TetherToken 合约准备的,其逻辑与前面提及的合约很相似,其代码如下:
contract TetherToken is Pausable, StandardToken, BlackList {
string public name;
string public symbol;
uint public decimals;
address public upgradedAddress;
bool public deprecated;
// 构造函数
function TetherToken(uint _initialSupply, string _name, string _symbol, uint _decimals) public {
_totalSupply = _initialSupply;
name = _name;
symbol = _symbol;
decimals = _decimals;
// 刚部署时,所有代币在合约所有者的账户下
balances[owner] = _initialSupply;
// 是否弃用
deprecated = false;
}
// 转账函数
function transfer(address _to, uint _value) public whenNotPaused {
// msg.sender不在黑名单中,才允许转账
require(!isBlackListed[msg.sender]);
// 如果当前合约已销毁,则走新合约的transferByLegacy方法完成交易
if (deprecated) {
return UpgradedStandardToken(upgradedAddress).transferByLegacy(msg.sender, _to, _value);
} else {
// 调用父类的transfer方法
return super.transfer(_to, _value);
}
}
// 转账交易
function transferFrom(address _from, address _to, uint _value) public whenNotPaused {
require(!isBlackListed[_from]);
if (deprecated) {
return UpgradedStandardToken(upgradedAddress).transferFromByLegacy(msg.sender, _from, _to, _value);
} else {
return super.transferFrom(_from, _to, _value);
}
}
// 余额
function balanceOf(address who) public constant returns (uint) {
if (deprecated) {
return UpgradedStandardToken(upgradedAddress).balanceOf(who);
} else {
return super.balanceOf(who);
}
}
// 授权
function approve(address _spender, uint _value) public onlyPayloadSize(2 * 32) {
if (deprecated) {
return UpgradedStandardToken(upgradedAddress).approveByLegacy(msg.sender, _spender, _value);
} else {
return super.approve(_spender, _value);
}
}
// _spender还可以操作_owner中多少代币
function allowance(address _owner, address _spender) public constant returns (uint remaining) {
if (deprecated) {
return StandardToken(upgradedAddress).allowance(_owner, _spender);
} else {
return super.allowance(_owner, _spender);
}
}
// 弃用当前合同以支持新合同
function deprecate(address _upgradedAddress) public onlyOwner {
// 将销毁标识符设置为true
deprecated = true;
// 新合约地址,新合约中,需要实现UpgradedStandardToken合约定义的接口方法
upgradedAddress = _upgradedAddress;
// 广播升级事件
Deprecate(_upgradedAddress);
}
// 总代币量
function totalSupply() public constant returns (uint) {
if (deprecated) {
return StandardToken(upgradedAddress).totalSupply();
} else {
return _totalSupply;
}
}
// 发行新的代币,新增代币到合约所有者的账户下
function issue(uint amount) public onlyOwner {
require(_totalSupply + amount > _totalSupply);
require(balances[owner] + amount > balances[owner]);
// 新代币发送到合约所有者的账户下
balances[owner] += amount;
// 代币总量增加
_totalSupply += amount;
Issue(amount);
}
// 赎回代币,减少合约所有者对应的代币量
function redeem(uint amount) public onlyOwner {
require(_totalSupply >= amount);
require(balances[owner] >= amount);
_totalSupply -= amount;
balances[owner] -= amount;
Redeem(amount);
}
// 修改手续费的基点率和最大手续费
function setParams(uint newBasisPoints, uint newMaxFee) public onlyOwner {
require(newBasisPoints < 20);
require(newMaxFee < 50);
// 基点率
basisPointsRate = newBasisPoints;
// 最大手续费
maximumFee = newMaxFee.mul(10**decimals);
Params(basisPointsRate, maximumFee);
}
event Issue(uint amount);
event Redeem(uint amount);
event Deprecate(address newAddress);
event Params(uint feeBasisPoints, uint maxFee);
}
上述代码中,给了较多注释,其逻辑也比较简单,就不多赘述了。
USDT 可以说是我们最常用的稳定币,但它其实是比较中心化的,比如它有黑名单机制、有发币机制、有销毁机制,这些操作都只能由合约创建者去操作,即还是中心化的管理方式。
此外,USDT 的黑名单机制也可以用来制作骗局,当你那天看见自己的账户多了一笔 USDT,不要激动,小心被骗,因为骗局本身的内容也比较多,同样挖坑留到后面再写。
最后,如果本文对你有所帮助,可以赞赏一波,我是二两,下篇文章见。