ERC777 功能型代幣(通證)最佳實踐
- 2019 年 10 月 11 日
- 筆記
想必很多同學都已經使用過ERC20 創建過代幣[1],或許已經被老闆要求在ERC20代幣上實現一些附加功能搞的焦頭爛額,如果還有選擇,一定要選擇 ERC777 。
ERC20 的問題
以下是一個遇到很多次的場景:有一天老闆過來找你(開發者),最近存幣生息很火,我們也做一個合約吧, 用戶打幣過來給他計算利息, 看起來是一個很簡單的需求,你滿口答應說好,結果自己一研究發現,使用 ERC20 標準沒辦法在合約里記錄是誰發過來多少幣,從而沒法計算利息(因為接收者合約並不知道自己接收到ERC20代幣)。
ERC20 標準下,可以通過一個變通的辦法,採用兩個交易組合完成,方法是:第1步:先讓用戶把要轉移的金額用 ERC20 的approve 授權的存幣生息合約(這步通常稱為解鎖),第2步:再次讓用戶調用存幣生息合約的計息函數,計息函數中通過 transferFrom 把代幣從用戶手裡轉移的合約內,並開始計息。
同樣由於ERC20 標準沒有一個轉賬通知機制,很多ERC20代幣誤轉到合約之後,再也沒有辦法把幣轉移出來,已經有大量的ERC20 因為這個原因被鎖死,如鎖死的QTUM[2],鎖死的EOS[3] 。
另外一個問題是ERC20 轉賬時,無法攜帶額外的信息,例如:我們有一些客戶希望讓用戶使用 ERC20 代幣購買商品,因為轉賬沒法攜帶額外的信息, 用戶的代幣轉移過來,不知道用戶具體要購買哪件商品,從而展加了線下額外的溝通成本。
ERC777很好的解決了這些問題,同時ERC777 也兼容 ERC20 標準。因此強烈建議新開發的代幣使用ERC777標準。
ERC777 在 ERC20的基礎上定義了 send(dest, value, data)
來轉移代幣, send函數額外的參數用來攜帶其他的信息,send函數會檢查持有者和接收者是否實現了相應的鉤子函數,如果有實現(不管是普通用戶地址還是合約地址都可以實現鉤子函數),則調用相應的鉤子函數。
ERC1820 接口註冊表合約
即便是一個普通用戶地址,同樣可以實現對 ERC777 轉賬的監聽, 聽起來有點神奇,其實這是通過 ERC1820 接口註冊表合約來是實現的。
ERC1820 如此的重要,以至於ERC777單獨把它拆出來作為一個EIP。
ERC1820 是一個全局的合約,有一個唯一在以太坊鏈上都相同的合約地址,它總是 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24
,這個合約是通過非常巧妙的方式進行部署的,有興趣的同學可以閱讀EIP1820文檔[4]。
ERC 1820 合約的官方實現代碼在ERC1820文檔[5]可以查閱,這裡說明合約實現的主要內容。
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代碼庫[6] 里找到,路徑為: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
攜帶操作者的信息,發送代幣除了執行對應賬戶的餘額加減和觸發事件之外,還有額外的規定:
1.如果持有者有通過 ERC1820 註冊 ERC777TokensSender
實現接口, 代幣合約必須調用其 tokensToSend
鉤子函數。2.如果接收者有通過 ERC1820 註冊 ERC777TokensRecipient
實現接口, 代幣合約必須調用其 tokensReceived
鉤子函數。3.如果有 tokensToSend
鉤子函數,必須在修改餘額狀態之前調用。4.如果有 tokensReceived
鉤子函數,必須在修改餘額狀態之後調用。5.調用鉤子函數及觸發事件時, 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事件,因為很多代幣,是在創建的時候就確定好代幣的數量。如果有需要合約可以自己定義鑄幣函數,鑄幣函數在實現時要求:
1.必須觸發Minted事件2.發行量需要加上鑄幣量, 接收者是不為 0 ,且接收者餘額加上鑄幣量。3.如果接收者有通過 ERC1820 註冊 ERC777TokensRecipient 實現接口, 代幣合約必須調用其 tokensReceived 鉤子函數。
ERC777 定義了兩個函數用於銷毀代幣 (burn
和 operatorBurn
),可以方便錢包和dapps有統一的接口交互。burn
和 operatorBurn
的實現要求:
1.必須觸發Burned事件。2.總供應量必須減少代幣銷毀量, 持有者的餘額必須減少代幣銷毀的數量。3.如果持有者通過ERC1820註冊ERC777TokensSender 實現,必須調用持有者的tokensToSend鉤子函數。
注意,零個代幣數量的交易(不管是轉移、鑄幣與銷毀)也是合法的,同樣滿足粒度(granularity) 的整數倍,因此需要正確處理。
ERC777 代幣實現
OpenZeppelin 實現了一個 ERC777 基礎合約,要實現自己的ERC777代幣只需要繼承 OpenZeppelin ERC777。想了解 OpenZeppelin 的 ERC777 的實現可閱讀ERC777 源碼解析[7]。
如果大家是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 中文文檔[8]。
功德箱這個實例僅僅是拋磚引玉,告訴大家如何實現收款時的回調,之後有時間,我寫一個完整的存幣生息應用。
普通賬戶地址監聽代幣轉出
功德箱合約的例子,收款地址和收款監聽是同一個合約, 現在來看看一個普通的用戶地址,如何委託一個合約來監聽代幣的轉出。監聽代幣的轉出可以讓持有者對發出去的代幣有更多的控制,例如持有者可以設置一些黑名單,禁止操作員對黑名單內賬號轉賬,
本部分的內容請訂閱我的小專欄[9]查看。
點擊「閱讀原文」訂閱小專欄。
References
[1]
ERC20 創建過代幣: https://learnblockchain.cn/2018/01/12/create_token/ [2]
鎖死的QTUM: https://etherscan.io/address/0x9a642d6b3368ddc662CA244bAdf32cDA716005BC [3]
鎖死的EOS: https://etherscan.io/address/0x86fa049857e0209aa7d9e616f7eb3b3b78ecfdb0 [4]
EIP1820文檔: https://learnblockchain.cn/docs/eips/eip-1820.html [5]
ERC1820文檔: https://learnblockchain.cn/docs/eips/eip-1820.html [6]
openzeppelin代碼庫: https://github.com/OpenZeppelin/openzeppelin-contracts [7]
ERC777 源碼解析: https://learnblockchain.cn/2019/09/26/erc777-code/ [8]
eip-1820 中文文檔: https://learnblockchain.cn/docs/eips/eip-1820.html [9]
我的小專欄: https://xiaozhuanlan.com/topic/5920148376