转自 https://learnblockchain.cn/2019/09/27/erc777/
想必很多人都已经使用过ERC20 创建过代币,或许已经被老板要求在ERC20代币上实现一些附加功能搞的焦头烂额,如果还有选择,一定要选择 ERC777 。
ERC20 的问题
以下是一个遇到很多次的场景:有一天老板过来找你(开发者),最近存币生息很火,我们也做一个合约吧, 用户打币过来给他计算利息, 看起来是一个很简单的需求,你满口答应说好,结果自己一研究发现,使用 ERC20 标准没办法在合约里记录是谁发过来多少币,从而没法计算利息(因为接收者合约并不知道自己接收到ERC20代币)。
ERC20 标准下,可以通过一个变通的办法,采用两个交易组合完成,方法是:第1步:先让用户把要转移的金额用 ERC20 的approve 授权的存币生息合约(这步通常称为解锁),第2步:再次让用户调用存币生息合约的计息函数,计息函数中通过 transferFrom 把代币从用户手里转移的合约内,并开始计息。
同样由于ERC20 标准没有一个转账通知机制,很多ERC20代币误转到合约之后,再也没有办法把币转移出来,已经有大量的ERC20 因为这个原因被锁死,如锁死的QTUM,锁死的EOS 。
另外一个问题是ERC20 转账时,无法携带额外的信息,例如:我们有一些客户希望让用户使用 ERC20 代币购买商品,因为转账没法携带额外的信息, 用户的代币转移过来,不知道用户具体要购买哪件商品,从而展加了线下额外的沟通成本。
ERC777很好的解决了这些问题,同时ERC777 也兼容 ERC20 标准。因此强烈建议新开发的代币使用ERC777标准。
ERC777 在 ERC20的基础上定义了 send(dest, value, data)
来转移代币, send函数额外的参数用来携带其他的信息,send函数会检查持有者和接收者是否实现了相应的钩子函数,如果有实现(不管是普通用户地址还是合约地址都可以实现钩子函数),则调用相应的钩子函数。
ERC1820 接口注册表合约
即便是一个普通用户地址,同样可以实现对 ERC777 转账的监听, 听起来有点神奇,其实这是通过 ERC1820 接口注册表合约来是实现的。
ERC1820 如此的重要,以至于ERC777单独把它拆出来作为一个EIP。
ERC1820 是一个全局的合约,有一个唯一在以太坊链上都相同的合约地址,它总是 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24
,这个合约是通过非常巧妙的方式进行部署的,有兴趣的同学可以阅读EIP1820文档。
ERC 1820 合约的官方实现代码在ERC1820文档可以查阅,这里说明合约实现的主要内容。
ERC1820合约提过了两个主要接口:
- setInterfaceImplementer(address _addr, bytes32 _interfaceHash, address _implementer)
用来设置地址(_addr)的接口(_interfaceHash 接口名称的 keccak256 )由哪个合约实现(_implementer)。 - getInterfaceImplementer(address _addr, bytes32 _interfaceHash) external view returns (address)
这个函数用来查询地址(_addr)的接口由哪个合约实现。
setInterfaceImplementer函数会参数信息记录到下面这个interfaces映射里:
// 记录 地址(第一个键) 的接口(第二个键)的实现地址(第二个值)
mapping(address => mapping(bytes32 => address)) interfaces;
相对应的 getInterfaceImplementer() 通过 interfaces 这个mapping 来获得接口的实现。
ERC777 使用 send转账时会分别在持有者和接收者地址上使用ERC1820 的getInterfaceImplementer函数进行查询,查看是否有对应的实现合约,ERC777 标准规范里预定了接口及函数名称,如果有实现则进行相应的调用。
ERC777 标准规范
ERC777 接口
ERC777 为了在实现上可以兼容ERC20,除了查询函数和ERC20一致外,操作接口均采用的独立的命名(避免相同的命令无法分辨是哪个标准),ERC777的接口定义如下,要求所有的ERC777代币合约都必须实现这些接口:
interface ERC777Token {
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function totalSupply() external view returns (uint256);
function balanceOf(address holder) external view returns (uint256);
// 定义代币最小的划分粒度
function granularity() external view returns (uint256);
// 操作员 相关的操作(操作员是可以代表持有者发送和销毁代币的账号地址)
function defaultOperators() external view returns (address[] memory);
function isOperatorFor(
address operator,
address holder
) external view returns (bool);
function authorizeOperator(address operator) external;
function revokeOperator(address operator) external;
// 发送代币
function send(address to, uint256 amount, bytes calldata data) external;
function operatorSend(
address from,
address to,
uint256 amount,
bytes calldata data,
bytes calldata operatorData
) external;
// 销毁代币
function burn(uint256 amount, bytes calldata data) external;
function operatorBurn(
address from,
uint256 amount,
bytes calldata data,
bytes calldata operatorData
) external;
// 发送代币事件
event Sent(
address indexed operator,
address indexed from,
address indexed to,
uint256 amount,
bytes data,
bytes operatorData
);
// 铸币事件
event Minted(
address indexed operator,
address indexed to,
uint256 amount,
bytes data,
bytes operatorData
);
// 销毁代币事件
event Burned(
address indexed operator,
address indexed from,
uint256 amount,
bytes data,
bytes operatorData
);
// 授权操作员事件
event AuthorizedOperator(
address indexed operator,
address indexed holder
);
// 撤销操作员事件
event RevokedOperator(address indexed operator, address indexed holder);
}
接口定义在 openzeppelin代码库 里找到,路径为:contracts/token/ERC777/IERC777.sol
。
接口说明与实现约定
所有的ERC777 合约除了必须实现上述接口,还有一些其他的必须遵守的约定(直接导致了ERC777官方文档又长又臭...哭~)。
ERC777 合约必须要通过 ERC1820 注册 ERC777Token
接口,这样任何人都可以查询合约是否是ERC777标准的合约,注册方法是: 调用ERC1820 注册合约的 setInterfaceImplementer 方法,参数 _addr 及 _implementer 均是合约的地址,_interfaceHash 是 ERC777Token
的 keccak256 哈希值(0xac7fbab5...177054)
如果 ERC777 要实现ERC20标准,还必须通过ERC1820 注册ERC20Token
接口。
ERC777 信息说明函数
name(),symbol(),totalSupply(),balanceOf(address) 和含义和在ERC20 中完全一样。
granularity() 用来定义代币最小的划分粒度(>=1), 要求必须在创建时设定,之后不可以更改,不管是在铸币、发送还是销毁操作的代币数量,必需是粒度的整数倍。
granularity 和 ERC20 的 decimals 不一样,decimals用来定义小数位数,decimals 是ERC20 可选函数,为了兼容 ERC20 代币, decimals 函数要求必须返回18。而 granularity 表示的是基于最小位数(内部存储)的划分粒度。例如:0.5个代币存储为
500,000,000,000,000,000
(0.5 X 10^18),如果粒度为2,则最小转账单位是2(相对于500,000,000,000,000,000
)。
操作员
ERC777 定义了一个新的操作员角色,操作员被作为移动代币的地址。 每个地址直观地移动自己的代币,将持有人和操作员的概念分开可以提供更大的灵活性。
与ERC20中的 approve 、 transferFrom 不同,其未明确定义批准地址的角色。
此外,ERC777还可以定义默认操作员(默认操作员列表只能在代币创建时定义的,并且不能更改),默认操作员是被所有持有人授权的操作员,这可以为项目方管理代币带来方便,当然认何持有人仍然有权撤销默认操作员。
操作员相关的函数:
- defaultOperators(): 获取代币合约默认的操作员列表.
- authorizeOperator(address operator): 设置一个地址作为msg.sender 的操作员,需要触发AuthorizedOperator事件。
- revokeOperator(address operator): 移除 msg.sender 上 operator 操作员的权限, 需要触发RevokedOperator事件。
- isOperatorFor(address operator, address holder): 是否是某个持有者的操作员。
发送代币
ERC777 发送代币 使用以下两个方法:
send(address to, uint256 amount, bytes calldata data) external
function operatorSend(
address from,
address to,
uint256 amount,
bytes calldata data,
bytes calldata operatorData
) external
operatorSend 可以通过参数operatorData
携带操作者的信息,发送代币除了执行对应账户的余额加减和触发事件之外,还有额外的规定:
- 如果持有者有通过 ERC1820 注册
ERC777TokensSender
实现接口, 代币合约必须调用其tokensToSend
钩子函数。 - 如果接收者有通过 ERC1820 注册
ERC777TokensRecipient
实现接口, 代币合约必须调用其tokensReceived
钩子函数。 - 如果有
tokensToSend
钩子函数,必须在修改余额状态之前调用。 - 如果有
tokensReceived
钩子函数,必须在修改余额状态之后调用。 - 调用钩子函数及触发事件时,
data
和operatorData
必须原样传递,因为 tokensToSend 和 tokensReceived 函数可能根据这个数据取消转账(触发revert
)。
ERC777TokensSender 接口定义如下:
interface ERC777TokensSender {
function tokensToSend(
address operator,
address from,
address to,
uint256 amount,
bytes calldata userData,
bytes calldata operatorData
) external;
}
如果持有者希望在转账时收到代币转移通知,就需要在ERC1820合约上注册及实现 ERC777TokensSender
接口(稍后有案例介绍)。
有一个地方需要注意: 对于所有的 ERC777 合约, 一个持有者地址只能注册一个ERC777TokensSender接口实现。因此 ERC777TokensSender 实现会被多个ERC777合约调用,在ERC777TokensSender接口的实现合约里, msg.sender 是ERC777合约地址,而不是操作者。
ERC777TokensRecipient 接口定义如下:
interface ERC777TokensRecipient {
function tokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes calldata data,
bytes calldata operatorData
) external;
}
如果接收者希望在转账时收到代币转移通知,就需要在ERC1820合约上注册及实现 ERC777TokensRecipient
接口。
如果接收者是一个合约地址, 则必须要注册及实现 ERC777TokensRecipient
接口(这样可以防止代币被锁死),如果没有实现,ERC777代币合约必须revert
回退交易状态。
铸币与销毁
铸币(挖矿)是产生新币的过程,销毁代币则相反,在ERC20 中,没有明确定义这两个行为,通常会transfer方法和Transfer事件来表达。
ERC777 则定义了代币从铸币、转移到销毁的整个生命周期。
ERC777 没有定义铸币的方法名,只定义了 Minted事件,因为很多代币,是在创建的时候就确定好代币的数量。
如果有需要合约可以自己定义铸币函数,铸币函数在实现时要求:
- 必须触发Minted事件
- 发行量需要加上铸币量, 接收者是不为 0 ,且接收者余额加上铸币量。
- 如果接收者有通过 ERC1820 注册 ERC777TokensRecipient 实现接口, 代币合约必须调用其 tokensReceived 钩子函数。
ERC777 定义了两个函数用于销毁代币 (burn
和 operatorBurn
),可以方便钱包和dapps有统一的接口交互。burn
和 operatorBurn
的实现要求:
- 必须触发Burned事件。
- 总供应量必须减少代币销毁量, 持有者的余额必须减少代币销毁的数量。
- 如果持有者通过ERC1820注册ERC777TokensSender 实现,必须调用持有者的tokensToSend钩子函数。
注意,零个代币数量的交易(不管是转移、铸币与销毁)也是合法的,同样满足粒度(granularity) 的整数倍,因此需要正确处理。
ERC777 代币实现
OpenZeppelin 实现了一个 ERC777 基础合约,要实现自己的ERC777代币只需要继承 OpenZeppelin ERC777。想了解 OpenZeppelin 的 ERC777 的实现可阅读ERC777 源码解析。
如果大家是Truffle开发(或者是Node工程),可以使用以下方式安装 OpenZeppelin 合约库:
npm install @openzeppelin/contracts
发行一个 2100 个的 LBC7 代币的代码就很简单了:
pragma solidity ^0.5.0;
import "@openzeppelin/contracts/token/ERC777/ERC777.sol";
contract MyERC777 is ERC777 {
constructor(
address[] memory defaultOperators
)
ERC777("MyERC777", "LBC7", defaultOperators)
public
{
uint initialSupply = 2100 * 10 ** 18;
_mint(msg.sender, msg.sender, initialSupply, "", "");
}
}
实现主要是两步:通过基类ERC777的构造函数确认代币名称、代号以及默认操作员(可为空),然后调用 _mint 初始化发行量,注意发行量的小数位是固定的18位(和ether保持一致),在合约内部是按小数位保存的,因此发行的币数需要乘上1018。
监听代币收款
我们假设有这样一个需求:寺庙要实现了一个功德箱合约接收捐赠,功德箱合约需要记录每位施主的善款金额。这时候就可以通过实现 ERC777TokensRecipient接口来完成。代码也很简单:
pragma solidity ^0.5.0;
import "@openzeppelin/contracts/token/ERC777/IERC777Recipient.sol";
import "@openzeppelin/contracts/token/ERC777/IERC777.sol";
import "@openzeppelin/contracts/introspection/IERC1820Registry.sol";
contract Merit is IERC777Recipient {
mapping(address => uint) public givers;
address _owner;
IERC777 _token;
IERC1820Registry private _erc1820 = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
// keccak256("ERC777TokensRecipient")
bytes32 constant private TOKENS_RECIPIENT_INTERFACE_HASH =
0xb281fc8c12954d22544db45de3159a39272895b169a852b314f9cc762e44c53b;
constructor(IERC777 token) public {
_erc1820.setInterfaceImplementer(address(this), TOKENS_RECIPIENT_INTERFACE_HASH, address(this));
_owner = msg.sender;
_token = token;
}
// 收款时被回调
function tokensReceived(
address operator,
address from,
address to,
uint amount,
bytes calldata userData,
bytes calldata operatorData
) external {
givers[from] += amount;
}
// 方丈取回功德箱token
function withdraw () external {
require(msg.sender == _owner, "no permision");
uint balance = _token.balanceOf(address(this));
_token.send(_owner, balance, "");
}
}
功德箱合约在构造时,调用 ERC1820 注册表合约的 setInterfaceImplementer函数 注册ERC777TokensRecipient接口实现(接口的实现是自身),这样在收到代币时,会回调 tokensReceived函数,tokensReceived函数通过givers映射来保存每个施主的善款金额。
注意: 如果是在本地的开发者网络环境,可能会没有ERC1820 注册表合约,如果没有需要先部署ERC1820注册表合约,参考eip-1820 中文文档。
功德箱这个实例仅仅是抛砖引玉,告诉大家如何实现收款时的回调,之后有时间,我写一个完整的存币生息应用。
普通账户地址监听代币转出
功德箱合约的例子,收款地址和收款监听是同一个合约, 现在来看看一个普通的用户地址,如何委托一个合约来监听代币的转出。
监听代币的转出可以让持有者对发出去的代币有更多的控制,例如持有者可以设置一些黑名单,禁止操作员对黑名单内账号转账,
本部分的内容请订阅我的小专栏查看。
通过实现 ERC777TokensSender 和 ERC777TokensRecipient 可以延伸出很多有意思的玩法,各位读者可以自行探索。
网友评论