智能合約教學(7) 去中心化交易所 依照預告,這次要實作的是去中心化交易所 先實際操作一遍去中心化交易所看看: https://vcckvv.github.io/SmartContract/TradeCoins/ 其中用到的合約程式碼為: https://goerli.etherscan.io/address/0x756e8B0b1997a0Bc590D2e7525a7f2a54F5ff401#code
1.首先還是一樣,要先有安裝MetaMask的瀏覽器外掛並且開啟起來,然後打開網頁後會要求連結MetaMask錢包、切換到goerli測試鏈 2.接著點選「Faucet」的按鈕切換到水龍頭頁面,如果沒有goerli的ETH幣就點選水龍頭網站連結去取得,也就是https://goerlifaucet.com/
3.之前沒有取得TEST幣的人點選「Add TEST Asset」按鈕來增加TEST代幣的資料,然後點選「Get TEST」簽署交易來獲得8個TEST代幣 4.這次新增另外一種測試代幣USD,合約程式碼在https://goerli.etherscan.io/address/0xcF364fb0cDBe5D8D4300B8030892E3e09e71D57B#code 上面可以看到,或是https://github.com/vcckvv/SmartContract/blob/gh-pages/TradeCoins/USDToken.sol ,基本上和TEST幣一樣,只是改了一些數字和名字,程式碼則是把之前多個檔案弄成一個檔案 點選「Add USD Asset」按鈕來增加USD代幣的資料,然後點選「Get USD」簽署交易來獲得1000個USD代幣 5.要注意領完幣還沒辦法進行完整的買賣,二種幣都有顯示Allowance的數字,Allowance的值代表你允許這個交易所合約可以自由動用你手中多少個代幣,這樣才有可能讓交易所把你手中的幣轉移到其他人上面以完成交易,如果為了謹慎的話可以只設定這次交易會動用到的金額數目,不過這裡為了省下麻煩,一次把Allowance設到最大,也就是2^256-1 這裡要做的事就是按下「Max TEST Allowance」、「Max USD Allowance」的按鈕後簽署交易,這樣就可以把二種幣的Allowance都調到最大 6.接下來按下「TradeCoins」按鈕回到交易頁面,上面會顯示上次的交易價格,以及排行前7低的賣單價格、排行前7高的買單價格,以及相應的掛單量,下面則是設置買賣單數據的表單 因為goerli鏈區塊產生時間通常大概12~14秒,所以這裡設定每15秒會自動更新一次顯示狀態 7.交易類型有二種:限價單(Limit Order)和市價單(Market Order),點選按鈕就能切換類型 限價單首先設定限定價格,買單要設定最大可接受價格,而賣單則是設定最小可接受價格,再設置給出的代幣數量就可以按下「Buy」、「Sell」按鈕來產生交易單子,之後放到系統中先尋找相配的買賣單進行交易撮合,同樣價格的話會優先找出較早發布的單子,然後盡可能把單子吃掉,直到自己單子的餘額不足以進行交易,或是找不到可以撮合的單子後就在系統中留下買賣單等待其他人的撮合 市價單的運作大致相同,但是吃完符合條件的單子後不會在系統中留下買賣單,而且可以把價格設為0或留空,這樣買單會自動變成價格最大值,賣單則自動變成價格最小值,會一直吃單子直到系統再也沒有單子為止,要注意市價單不會檢查有沒有吃到單子,所以也可能遇到沒吃到單子就結束交易的情況發生 為了方便,在填入價格或給出數量時會計算相應的獲得數量,或是填入獲得數量而計算出需要多少給出數量 在填入數字時會進行檢查,如果不是正常數字,那麼文字會變成紅色代表填錯了,而且代幣都會有限制小數點後的位數數量,一般代幣都是限制小數點後最多18位,而這裡的TEST幣則是小數點後最多2位 8.一旦在系統中留下單子後,可以按下「User」按鈕來到使用者的買賣單頁面,這邊可以看到目前手中有多少單子,以及這些單子內容的數據,按下「Delete Order」按鈕的話就可以把那個單子取消掉,合約系統會把裡面的餘額退給你
9.按下「Orders」按鈕可以看到系統中現在存在的單子,買賣單最多顯示前100名的單子,這個功能只是用來方便除錯
10.按下「Log」按鈕可以看到系統中所有記錄,這個記錄是用event的功能去產生的,之後會提到如何實作,記錄類型分成四種:AddOrder(增加買單或賣單)、DelOrder(取消單子)、TradeOrder(交易撮合)、Transfer(轉移代幣),這裡可以輸入區塊數來看特定時間範圍內的記錄
11.之後可以按上面的「Trading Pair」下拉選單,可以換成其他的交易對,這個系統預設有三種交易對:「ETH-TEST」、「USD-ETH」、「USD-TEST」,Log記錄或資料狀態都是分開來顯示的 ------------------------------------------ 系統介紹大概就是這樣,下面我們開始來講解程式碼,因為程式碼很多,所以這次就不全部貼上來只是重點式地講解,首先是合約程式碼的部份: https://github.com/vcckvv/SmartContract/blob/gh-pages/TradeCoins/TreeLibrary.sol https://github.com/vcckvv/SmartContract/blob/gh-pages/TradeCoins/TradeCoins.sol ------------------------------------------ 這次的程式會把買賣單進行排序,並要能夠把排名前幾的單子依序列出來,所以會用到紅黑樹的資料結構,不過solidity並沒有內建提供這種資料結構(mapping不能依序列出所有資料),所以這邊是使用別人寫好的紅黑樹程式: https://github.com/bokkypoobah/BokkyPooBahsRedBlackTreeLibrary/blob/master/contracts/BokkyPooBahsRedBlackTreeLibrary.sol 因為裡面沒有讀取節點數量的函數,以及檢查是否為空集合的函數,所以自己有簡單做了修改,其他部份則不變: https://github.com/vcckvv/SmartContract/blob/gh-pages/TradeCoins/TreeLibrary.sol 不過要使用solidity的資料結構必須先了解library和using for的使用方法,以下是簡單的範例: struct Data{ uint value; } library DataLib{ function setValue(Data storage self, uint v) external { self.value=v; } } contract Test{ using DataLib for Data; Data data; function test() public { data.setValue(123);//相當於呼叫DataLib中的setValue(data, 123) } } 個人不太喜歡這種設計,不過目前只能用這種方法來實作資料結構的話也只能習慣它 注意這裡的TreeLibrary只會儲存key,而不會儲存key對應的value,所以如果想使用C++中map那樣的資料結構的話,那麼就要用solidity的mapping去另外儲存映射資料,像是等下會說到的PriceTree 買賣單的資料結構是先以價格為key的紅黑樹PriceTree,然而同一個價格可能會存在多個單子,所以還需要另一個以單子ID為key的紅黑樹OrderTree: struct OrderTree{ TreeLibrary.Tree tree; } struct PriceTree{ TreeLibrary.Tree tree; mapping(uint => OrderTree) values; } 而在使用上,要判斷是不是沒有資料節點可以用`isEmpty()`,取得總節點數量可以用`getCount()`,判斷節點存不存在可以用`exists(key)`,插入新的節點可以用`insert(key)`,刪除則是用`remove(key)`,在PriceTree中取出key相對應的value可以用`getValue(key)` 如果要從小到大依序取出各個key,則是使用如下的方式: uint key=tree.first();//取得第一個key uint lastKey=tree.last();//取得最後一個key while(true){ ...//處理key的程式碼 if(key==lastKey){ break; } key=tree.next(key);//取得下一個key } 而如果要倒過來從大到小依序取出各個key,則是使用如下的方式: uint key=tree.last();//取得最後一個key uint firstKey=tree.first();//取得第一個key while(true){ ...//處理key的程式碼 if(key==firstKey){ break; } key=tree.prev(key);//取得上一個key } ------------------------------------------ 這次寫合約程式遇到的最大的麻煩是:Contract code size exceeds 24576 bytes 這個問題是因為合約編譯後的二進位資料限制最多只能有24576 bytes,所以你只要稍微寫大一點的合約程式,就會跳出這個警告,如果無視這個警告,那麼實際發布的時候就很可能會失敗,不過即使沒有這個限制,每個區塊依然會有gas上限,所以發布的合約資料仍然存在上限大小 那麼為了要解決這個問題,我們需要把合約中的程式碼一部份搬到library中實作,比如以下的範例函數: 原本的合約: contract Test{ function f(uint a) public { ...(很多的程式碼) } } 改造後的合約: library TestLib{ function f(uint a, Test test) external { ...(很多的程式碼,使用test存取合約中的資料) } } contract Test{ function f(uint a) public { TestLib.f(a, this); } } 不過因為有很多合約資料是不能讓外人隨意設定的,所以我實際在寫時還會用一個布林變數來加一道安全鎖: library TestLib{ function f(uint a, Test test) external { ...(很多的程式碼,使用test.data()和test.setData()來存取合約中的資料) } } contract Test{ uint public data; bool isLibCall=false; function setData(uint v) external { require(isLibCall);//只有TestLib才可以使用這個函數 data=v; } function f(uint a) public { isLibCall=true;//解鎖 TestLib.f(a, this); isLibCall=false;//加鎖 } } 注意這個方法要很確定`TestLib.f()`不會呼叫任何未知內容的外部合約函數,否則就等同把重大的修改權限交給其他人來使用 這次把合約TradeCoins的程式碼拆出一部份到TradeCoinsLib基本上就是按照這個架構在寫 ------------------------------------------ 第二個遇到的大問題是Stack Too Deep,意思就是EVM運行的Stack很小,所以只要在函數中多塞點local變數就會出現這個錯誤,這個時候就只能產生另外一個函數,呼叫這個函數來把一些需要local變數的操作移到其他的地方,或是使用struct把多個local變數包成一個local變數來操作 這次有用到的部份是Trade這個event(後面會解釋什麼是event),如果直接emit event的話就會跳出這個錯誤,所以我把它用另外一個函數eventTrade包裝起來,而且輸入參數是使用struct的TradeData(後面會說到),一樣把多個變數包成一個變數: event Trade(uint time, TradeType tradeType, address indexed buyUser, address indexed sellUser, uint32 indexed pairID, uint price, uint buyValue, uint sellValue, uint orderID, bool isEndOrder); function eventTrade(TradeData memory data) internal { emit Trade(block.timestamp, data.tradeType, data.buyUser, data.sellUser, data.pairID, data.price, data.buyValue, data.sellValue, data.orderID, data.isEndOrder); } ------------------------------------------ 還沒介紹過event,所以接下來講解一下,以下是範例: contract Test{ event msg(uint indexed key1, uint indexed key2, string memory message);//用indexed來設定key變數 function f() public { emit msg(1, 2, "message"); } } 一旦我們執行Test中f()這個函數,就會在鏈上留下一段記錄`msg(1, 2, "message")`,但這個記錄無法被合約存取,因為不被視為儲存的storage資料,不過我們可以在javascript中使用filter和queryFilter函數把這些資料讀取出來: let filter = testContract.filters.msg(); let data = await testContract.queryFilter(filter, startBlockNum, endBlockNum); 假設我們有執行過一次f(),並且資料是在區塊startBlockNum~endBlockNum之間,則我們會讀到data.length==1,並且可以用data[0].args[0]、data[0].args[1]、data[0].args[2]來各別取出key1、key2、message的資料 如果我們要指定某些key變數用來過濾搜尋的話,則可以這樣設定filter: let filter1 = testContract.filters.msg(key1);//符合key1就好 let filter2 = testContract.filters.msg(key1, key2);//符合key1和key2 let filter3 = testContract.filters.msg(null, key2);//符合key2就好 另外要注意indexed變數最多只能設3個,也就是只有三個變數能被當作key event的方便之處在於消耗gas會比一般的儲存資料要便宜很多,而且很適合用來除錯,可以檢視一些重要資料的狀態變化 這次為了方便log頁面一次把所有種類的記錄都顯示出來,所以把四種記錄都做成同一個event,然後用enum設計出四種種類的名字(AddOrder, DelOrder, TradeOrder, Transfer),而實際上在讀取tradeType的時候會是對應0~3的數字,然後有些欄位的變數在某些種類中不會被用到,有些變數則在某些種類中有別的意義: enum TradeType{ AddOrder, DelOrder, TradeOrder, Transfer } struct TradeData{ TradeType tradeType; address buyUser;//Transfer: fromAddr address sellUser;//Transfer: toAddr uint32 pairID; uint price; uint buyValue;//Transfer: token1Value uint sellValue;//Transfer: token2Value uint orderID; bool isEndOrder; } ------------------------------------------ 補上之前沒講到的修飾詞storage,以下面程式為例: Order memory order1=orderMap[id]; Order storage order2=orderMap[id]; order1是複製過來暫時的資料,所以對其進行寫入不會改到鏈上的資料 order2則是指向鏈上的資料,所以對其進行寫入會改到鏈上的資料,另外進行存取時所花費的gas費也會比較貴,所以原則上處理過程中盡量使用memory變數 另外像數值型態的區域變數,如uint、address、bool,都是不能加上修飾詞storage、memory,預設上都是memory存取,只有參考型態的區域變數,像是struct資料、mapping、array才可以使用storage去存取 ------------------------------------------ 這次有用到二種特別的變數修飾詞:immutable和constant address immutable founderAddr; uint constant public cnPriceStd=1018;//1018等同10的18次方 immutable代表這個變數在經過一開始constructor()執行後就不再改變數值,設定這個修飾詞可以使這個變數之後的讀取gas費都變少,就如同被當作不變的常數看待 constant就代表這個數是不變的常數,再加上public代表這個變數可以被外部讀取,不過外部讀取時要以函數的形式進行呼叫,也就是`cnPriceStd()` ------------------------------------------ 這次有用到另外三種函數修飾詞:internal、private、external internal代表這個函數只能被內部呼叫、不能被外部呼叫,不過可以進行繼承,private也是一樣只能被內部呼叫,不過不能被繼承 external則是反過來只能被外部呼叫、不能被內部呼叫,看資料說設定這樣的函數可以比全設成public函數要稍微省一些gas費 另外要注意,在library中的internal函數如果被contract引用,則程式碼資料會算在contract之中,所以要用public或external才不會讓contract變肥大 ------------------------------------------ 這次在吃單的過程中傳送原生代幣的方法是使用send()函數,而不是之前提到的transfer()函數,如下: function sendOrgCoin(address addr, uint value) internal { if(payable(addr).send(value)==false){ //遇到特殊合約可能出錯,這裡跳過錯誤,避免單子沒辦法被吃掉而卡住 } } send()和transfer()的差別在於send()出錯的話會回傳、繼續執行後續操作,而transfer()則是讓整個交易無效、後面的操作都無法執行 那麼什麼情況會出現傳送原生代幣錯誤呢,原因在於其他合約也可以成為交易者,並且呼叫這個合約的交易函數進行交易,但是傳送原生代幣給交易者合約時可以觸發那個合約的fallback函數,如下: contract FailSend { fallback() external payable { revert(); } } (在0.6.X之前的編譯器版本可以用沒有名字的函數當作fallback函數,不過現在新版就是寫成這樣) fallback函數會在二種情況下觸發,第一種是呼叫合約的函數時,但是其函數名字不符合現有函數的任一個,第二種是傳原生代幣給這個合約的位址,而上述的合約會使得任何傳送代幣的動作都會失敗,因為都會執行到revert() 為了避免遇到這種情況,所以我們這裡選擇使用send()來跳過錯誤,而不是用transfer()讓整個吃單系統卡住 ------------------------------------------ 雖然合約程式碼看起來很多,不過如果上面這些新講的東西都懂,然後也懂得基本的系統運作的話,那麼其他的部份閱讀起來應該都沒有問題,這裡就不再詳細解說裡面的細節,來談一下發布時會遇到的情況: 這次主要發布的合約是TradeCoins,因為裡面用到3個library:PriceTreeLib、OrderTreeLib、TradeCoinsLib,這些library要在TradeCoins發布之前都先發布出去,不過幸好remix可以幫我們依序發布並自動做好連結library,所以我們在發布的時候一樣只要選擇TradeCoins來發布,然後就會依序先把三個library發布,最後再發布出TradeCoins,過程就是簽署完等到上鏈後再簽署下一筆交易 另外,如果要在etherscan中進行Verify and Publish,那麼最好先把多個檔案的內容塞成一個檔案,然後在輸入程式碼的底下有一項Contract Library Address,在這裡輸入要連結的三個library和名字和發布位址,如果沒這麼做就會驗證失敗,比如這次的發布位址是: OrderTreeLib: 0xD94631D7f498cE63a3CC843a3A8302569E47c4ff PriceTreeLib: 0xFFC56B04A7D67fB0e565d9e938aba14c496C6060 TradeCoinsLib: 0x6344F360E4AE1bcF461A71843CbF92540a9Da023
------------------------------------------ 接下來轉移到網頁程式碼的解說部份: https://github.com/vcckvv/SmartContract/blob/gh-pages/TradeCoins/index.html 網頁的部份一樣不談論JavaScript和HTML的操作,之前有說過的web3的部份也不用多說,主要談這次有用到的新東西: 這段程式碼可以讀取到現在最新的區塊數 let nowBlockNum = await provider.getBlockNumber(); 這段程式碼可以讀取到特定區塊的時間(timestamp),然後再用我寫好的timestampToStr函數去把它轉成字串 timestampToStr((await provider.getBlock(startBlockNum) ).timestamp); ------------------------------------------ 在ethers函數庫中有提供`ethers.utils.formatUnits()`和`ethers.utils.parseUnits()`的函數來進行數字字串和BigNumber之間的轉換,比如: ethers.utils.formatUnits("1000000000000000000", 18);//產生1.0,相當於把數字除於10^18 ethers.utils.parseUnits("1.0", 18);//產生值為1000000000000000000的BigNumber,相當於把數字乘於10^18 實際使用上有些限制,所以我又另外寫了一個formatUnits()函數,可以進行正數或負數的位數轉換 而ethers函數庫的BigNumber可以做一些整數加減乘除、比較大小的運算,完整用法可以參考ethers的網頁說明: https://docs.ethers.io/v5/api/utils/bignumber/ ------------------------------------------ 其他的部份感覺就不用特別詳細解說了,有問題的話就另外留言問吧 這次難度好像有點跳太高了,不過如果只是單純講解solidity的各種語法還蠻無聊的,試著建立一個系統然後從中學習需要的東西,對我來說還是比較有趣一些 寫完後回顧了一下這個掛單簿系統,其實覺得掛單的gas費太貴,並不是適合實際上線的系統,所以一般才會採用AMM(Automated Market Maker,自動化做市商)的系統設計,AMM雖然也有缺點但好處就是交易gas費沒那麼貴,如果要做出有實用性的掛單簿系統可能要學dydx項目那樣在鏈下進行交易撮合,之後再把結果放到鏈上,不過那樣系統就太複雜了 想說的差不多就是這樣,這次就講解到這裡,下次來談談怎麼製作NFT合約
No comments yet