CryptoZombies是個在編游戲的過程中學(xué)習(xí)Solidity智能合約語言的互動教程。本教程是為了Solidity初學(xué)者而設(shè)計的,會從最基礎(chǔ)開始教起,即便你從來沒有接觸過Solidity也可以學(xué),CryptoZombies會手把手地教你。
今天我們來學(xué)習(xí)第3課高級Solidity理論,這堂課比之前要少些特效,但是會學(xué)一些非常重要的基礎(chǔ)理論,編寫真正的DApp時必知的:智能協(xié)議的所有權(quán),Gas的花費,代碼優(yōu)化,和代碼安全。
1. 智能協(xié)議的永固性
到現(xiàn)在為止,我們講的Solidity和其他語言沒有質(zhì)的區(qū)別,它長得也很像 JavaScript。但是,在有幾點以太坊上的DApp跟普通的應(yīng)用程序有著天壤之別。
第一個例子,在你把智能協(xié)議傳上以太坊之后,它就變得不可更改,這種永固性意味著你的代碼永遠(yuǎn)不能被調(diào)整或更新。你編譯的程序會一直、永久的、不可更改地存在以太網(wǎng)上。這就是Solidity代碼的安全性如此重要的一個原因。如果你的智能協(xié)議有任何漏洞,即使你發(fā)現(xiàn)了也無法補救。你只能讓你的用戶們放棄這個智能協(xié)議,然后轉(zhuǎn)移到一個新的修復(fù)后的合約上。
但這恰好也是智能合約的一大優(yōu)勢。 代碼說明一切。 如果你去讀智能合約的代碼,并驗證它,你會發(fā)現(xiàn),一旦函數(shù)被定義下來,每一次的運行,程序都會嚴(yán)格遵照函數(shù)中原有的代碼邏輯一絲不茍地執(zhí)行,完全不用擔(dān)心函數(shù)被人篡改而得到意外的結(jié)果。
2. Ownable Contracts
上一章中,你有沒有發(fā)現(xiàn)任何安全漏洞呢?對申明為“外部的”(external)方法,是任何人都可以調(diào)用它的,這種情況下可能出現(xiàn)安全漏洞。要對付這樣的情況,通常的做法是指定合約的“所有權(quán)”——就是說,給它指定一個主人,只有主人對它享有特權(quán)。
OpenZeppelin庫的Ownable合約
下面是一個Ownable合約的例子:來自O(shè)penZeppelin Solidity庫的 Ownable合約。OpenZeppelin是主打安保和社區(qū)審查的智能合約庫,您可以在自己的DApps中引用。這一課學(xué)完,可以看看OpenZeppelin,保管您會學(xué)到很多東西!
把樓下這個合約讀通,是不是還有些沒見過代碼?別擔(dān)心,隨后會解釋。
/**
* @title Ownable
* @dev The Ownable contract has an owner address, and provides basic authorization control
* functions, this simplifies the implementation of "user permissions".
*/
contract Ownable {
address public owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
/**
* @dev The Ownable constructor sets the original `owner` of the contract to the sender
* account.
*/
function Ownable() public {
owner = msg.sender;
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
/**
* @dev Allows the current owner to transfer control of the contract to a newOwner.
* @param newOwner The address to transfer ownership to.
*/
function transferOwnership(address newOwner) public onlyOwner {
require(newOwner != address(0));
OwnershipTransferred(owner, newOwner);
owner = newOwner;
}
}
上面面有沒有你沒學(xué)過的東東?
構(gòu)造函數(shù):function Ownable()
是一個constructor
(構(gòu)造函數(shù)),構(gòu)造函數(shù)不是必須的,它與合約同名,構(gòu)造函數(shù)一生中唯一的一次執(zhí)行,就是在合約最初被創(chuàng)建的時候。
函數(shù)修飾符:modifier onlyOwner()
。 修飾符跟函數(shù)很類似,不過是用來修飾其他已有函數(shù)用的, 在其他語句執(zhí)行前,為它檢查下先驗條件。 在這個例子中,我們就可以寫個修飾符onlyOwner檢查下調(diào)用者,確保只有合約的主人才能運行本函數(shù)。我們下一章中會詳細(xì)講述修飾符,以及那個奇怪的_;
。
indexed關(guān)鍵字:別擔(dān)心,我們還用不到它。
所以O(shè)wnable合約基本都會這么干:
合約創(chuàng)建,構(gòu)造函數(shù)先行,將其owner設(shè)置為msg.sender(其部署者)
為它加上一個修飾符onlyOwner,它會限制陌生人的訪問,將訪問某些函數(shù)的權(quán)限鎖定在 owner 上。
允許將合約所有權(quán)轉(zhuǎn)讓給他人。
onlyOwner簡直人見人愛,大多數(shù)人開發(fā)自己的Solidity DApps,都是從復(fù)制/粘貼 Ownable開始的,從它再繼承出的子類,并在之上進(jìn)行功能開發(fā)。
3.onlyOwner 函數(shù)修飾符
函數(shù)修飾符
函數(shù)修飾符看起來跟函數(shù)沒什么不同,不過關(guān)鍵字modifier告訴編譯器,這是個modifier(修飾符),而不是個function(函數(shù))。它不能像函數(shù)那樣被直接調(diào)用,只能被添加到函數(shù)定義的末尾,用以改變函數(shù)的行為。咱們仔細(xì)讀讀onlyOwner
:
/**
* @dev 調(diào)用者不是‘主人’,就會拋出異常
*/
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
onlyOwner
函數(shù)修飾符是這么用的:
contract MyContract is Ownable {
event LaughManiacally(string laughter);
//注意! `onlyOwner`上場 :
function likeABoss() external onlyOwner {
LaughManiacally("Muahahahaha");
}
}
注意likeABoss
函數(shù)上的onlyOwner
修飾符。 當(dāng)你調(diào)用likeABoss
時,首先執(zhí)行onlyOwner
中的代碼, 執(zhí)行到onlyOwner
中的_;
語句時,程序再返回并執(zhí)行likeABoss
中的代碼。可見,盡管函數(shù)修飾符也可以應(yīng)用到各種場合,但最常見的還是放在函數(shù)執(zhí)行之前添加快速的require檢查。
因為給函數(shù)添加了修飾符`onlyOwner,使得唯有合約的主人(也就是部署者)才能調(diào)用它。
注意:主人對合約享有的特權(quán)當(dāng)然是正當(dāng)?shù)模贿^也可能被惡意使用。比如,萬一,主人添加了個后門。所以非常重要的是,部署在以太坊上的DApp,并不能保證它真正做到去中心,你需要閱讀并理解它的源代碼,才能防止其中沒有被部署者惡意植入后門;作為開發(fā)人員,如何做到既要給自己留下修復(fù)bug的余地,又要盡量地放權(quán)給使用者,以便讓他們放心你,從而愿意把數(shù)據(jù)放在你的 DApp中,這確實需要個微妙的平衡。
4.Gas
現(xiàn)在我們懂了如何在禁止第三方修改我們合約的同時,留個后門給咱們自己去修改。讓我們來看另一種使得Solidity編程語言與眾不同的特征:Gas——驅(qū)動以太坊DApps的能源。
在Solidity中,你的用戶想要每次執(zhí)行你的DApp都需要支付一定的gas,gas可以用以太幣購買,因此,用戶每次跑DApp都得花費以太幣。一個DApp收取多少gas取決于功能邏輯的復(fù)雜程度。每個操作背后,都在計算完成這個操作所需要的計算資源,(比如,存儲數(shù)據(jù)就比做個加法運算貴得多), 一次操作所需要花費的gas等于這個操作背后的所有運算花銷的總和。
由于運行你的程序需要花費用戶的真金白銀,在以太坊中代碼的編程語言,比其他任何編程語言都更強調(diào)優(yōu)化。同樣的功能,使用笨拙的代碼開發(fā)的程序,比起經(jīng)過精巧優(yōu)化的代碼來,運行花費更高,這顯然會給成千上萬的用戶帶來大量不必要的開銷。
為什么要用 gas 來驅(qū)動?
以太坊就像一個巨大、緩慢、但非常安全的電腦。當(dāng)你運行一個程序的時候,網(wǎng)絡(luò)上的每一個節(jié)點都在進(jìn)行相同的運算,以驗證它的輸出——這就是所謂的“去中心化”,由于數(shù)以千計的節(jié)點同時在驗證著每個功能的運行,這可以確保它的數(shù)據(jù)不會被被監(jiān)控,或者被刻意修改。
可能會有用戶用無限循環(huán)堵塞網(wǎng)絡(luò),抑或用密集運算來占用大量的網(wǎng)絡(luò)資源,為了防止這種事情的發(fā)生,以太坊的創(chuàng)建者為以太坊上的資源制定了價格,想要在以太坊上運算或者存儲,你需要先付費。
注意:如果你使用側(cè)鏈,倒是不一定需要付費。你不會想要在以太坊主網(wǎng)上玩“魔獸世界”吧?所需要的gas可能會買到你破產(chǎn)。但是你可以找個算法理念不同的側(cè)鏈來玩它。我們將在以后的課程中咱們會討論到,什么樣的 DApp應(yīng)該部署在太坊主鏈上,什么又最好放在側(cè)鏈。
省gas的招數(shù):結(jié)構(gòu)封裝(Struct packing)
在第1課中,我們提到除了基本版的 uint 外,還有其他變種uint:uint8,uint16,uint32等。通常情況下我們不會考慮使用uint變種,因為無論如何定義uint的大小,Solidity為它保留256位的存儲空間。例如,使用uint8而不是uint(uint256)不會為你節(jié)省任何 gas。除非,把uint綁定到struct里面。
如果一個struct中有多個uint,則盡可能使用較小的uint,Solidity會將這些 uint打包在一起,從而占用較少的存儲空間。例如:
struct NormalStruct {
uint a;
uint b;
uint c;
}
struct MiniMe {
uint32 a;
uint32 b;
uint c;
}
因為使用了結(jié)構(gòu)打包,mini
比 normal
占用的空間更少
NormalStruct normal = NormalStruct(10, 20, 30);
MiniMe mini = MiniMe(10, 20, 30);
所以,當(dāng)uint 定義在一個struct中的時候,盡量使用最小的整數(shù)子類型以節(jié)約空間。 并且把同樣類型的變量放一起(即在 struct 中將把變量按照類型依次放置),這樣Solidity可以將存儲空間最小化。例如,有兩個 struct:
uint c; uint32 a; uint32 b; 和 uint32 a; uint c; uint32 b;
前者比后者需要的gas更少,因為前者把uint32放一起了。
5. 時間單位
時間單位
Solidity使用自己的本地時間單位。變量now將返回當(dāng)前的unix時間戳(自1970年1月1日以來經(jīng)過的秒數(shù))。
注意:Unix時間傳統(tǒng)用一個32位的整數(shù)進(jìn)行存儲。這會導(dǎo)致“2038年”問題,當(dāng)這個32位的unix時間戳不夠用,產(chǎn)生溢出,使用這個時間的遺留系統(tǒng)就麻煩了。所以,如果我們想讓我們的DApp跑夠20年,我們可以使用64位整數(shù)表示時間,但為此我們的用戶又得支付更多的gas。真是個兩難的設(shè)計!
Solidity還包含秒(seconds),分鐘(minutes),小時(hours),天(days),周(weeks) 和年(years) 等時間單位。它們都會轉(zhuǎn)換成對應(yīng)的秒數(shù)放入uint 中。所以1分鐘就是 60,1小時是 3600(60秒×60分鐘),1天是86400(24小時×60分鐘×60秒),以此類推。
下面是一些使用時間單位的實用案例:
uint lastUpdated;
// 將‘上次更新時間’ 設(shè)置為 ‘現(xiàn)在’
function updateTimestamp() public {
lastUpdated = now;
}
// 如果到上次`updateTimestamp` 超過5分鐘,返回 'true'
// 不到5分鐘返回 'false'
function fiveMinutesHavePassed() public view returns (bool) {
return (now >= (lastUpdated + 5 minutes));
}
有了這些工具,我們可以為僵尸設(shè)定”冷靜時間“功能。
6. 公有函數(shù)和安全性
你必須仔細(xì)地檢查所有聲明為public和external的函數(shù),一個個排除用戶濫用它們的可能,謹(jǐn)防安全漏洞。請記住,如果這些函數(shù)沒有類似onlyOwner這樣的函數(shù)修飾符,用戶能利用各種可能的參數(shù)去調(diào)用它們。檢查完這個函數(shù),用戶就可以直接調(diào)用這個它。想要防止漏洞,最簡單的方法就是設(shè)其可見性為internal。
7.進(jìn)一步了解函數(shù)修飾符
接下來,我們將添加一些輔助方法。進(jìn)一步學(xué)習(xí)什么是“函數(shù)修飾符”。
帶參數(shù)的函數(shù)修飾符
之前我們已經(jīng)讀過一個簡單的函數(shù)修飾符了:onlyOwner。函數(shù)修飾符也可以帶參數(shù)。例如:
// 存儲用戶年齡的映射
mapping (uint => uint) public age;
// 限定用戶年齡的修飾符
modifier olderThan(uint _age, uint _userId) {
require(age[_userId] >= _age);
_;
}
必須年滿16周歲才允許開車 (至少在美國是這樣的),我們可以用如下參數(shù)調(diào)用olderThan
修飾符:
function driveCar(uint _userId) public olderThan(16, _userId) {
// 其余的程序邏輯
}
看到了吧, olderThan修飾符可以像函數(shù)一樣接收參數(shù),是“宿主”函數(shù) driveCar把參數(shù)傳遞給它的修飾符的。
8.利用 'View' 函數(shù)節(jié)省Gas
“view”函數(shù)不花“gas”
當(dāng)玩家從外部調(diào)用一個view函數(shù),是不需要支付gas的。這是因為view函數(shù)不會真正改變區(qū)塊鏈上的任何數(shù)據(jù)——它們只是讀取。因此用view標(biāo)記一個函數(shù),意味著告訴web3.js,運行這個函數(shù)只需要查詢你的本地以太坊節(jié)點,而不需要在區(qū)塊鏈上創(chuàng)建一個事務(wù)(事務(wù)需要運行在每個節(jié)點上,因此花費 gas)。
稍后我們將介紹如何在自己的節(jié)點上設(shè)置 web3.js。但現(xiàn)在,你關(guān)鍵是要記住,在所能只讀的函數(shù)上標(biāo)記上表示只讀的“external view”聲明,就能為你的玩家減少在DApp中g(shù)as用量。
注意:如果一個view函數(shù)在另一個函數(shù)的內(nèi)部被調(diào)用,而調(diào)用函數(shù)與view 函數(shù)的不屬于同一個合約,也會產(chǎn)生調(diào)用成本。這是因為如果主調(diào)函數(shù)在以太坊創(chuàng)建了一個事務(wù),它仍然需要逐個節(jié)點去驗證。所以標(biāo)記為view的函數(shù)只有同一個合約的外部調(diào)用時才是免費的。
9.存儲非常昂貴
Solidity使用storage(存儲)是相當(dāng)昂貴的,”寫入“操作尤其貴。這是因為,無論是寫入還是更改一段數(shù)據(jù), 這都將永久性地寫入?yún)^(qū)塊鏈。需要在全球數(shù)千個節(jié)點的硬盤上存入這些數(shù)據(jù),隨著區(qū)塊鏈的增長,拷貝份數(shù)更多,存儲量也就越大。這是需要成本的!
為了降低成本,不到萬不得已,避免將數(shù)據(jù)寫入存儲。這也會導(dǎo)致效率低下的編程邏輯——比如每次調(diào)用一個函數(shù),都需要在memory(內(nèi)存) 中重建一個數(shù)組,而不是簡單地將上次計算的數(shù)組給存儲下來以便快速查找。
在大多數(shù)編程語言中,遍歷大數(shù)據(jù)集合都是昂貴的。但是在Solidity中,使用一個標(biāo)記了external view的函數(shù),遍歷比storage要便宜太多,因為view函數(shù)不會產(chǎn)生任何花銷。
在內(nèi)存中聲明數(shù)組
在數(shù)組后面加上memory關(guān)鍵字, 表明這個數(shù)組是僅僅在內(nèi)存中創(chuàng)建,不需要寫入外部存儲,并且在函數(shù)調(diào)用結(jié)束時它就解散了。與在程序結(jié)束時把數(shù)據(jù)保存進(jìn)storage的做法相比,內(nèi)存運算可以大大節(jié)省gas開銷——把這數(shù)組放在view里用,完全不用花錢。以下是申明一個內(nèi)存數(shù)組的例子:
function getArray() external pure returns(uint[]) {
// 初始化一個長度為3的內(nèi)存數(shù)組
uint[] memory values = new uint[](3);
// 賦值
values.push(1);
values.push(2);
values.push(3);
// 返回數(shù)組
return values;
}
這個小例子展示了一些語法規(guī)則,下一章中,我們將通過一個實際用例,展示它和for循環(huán)結(jié)合的做法。
注意:內(nèi)存數(shù)組必須用長度參數(shù)(在本例中為3)創(chuàng)建。目前不支持 array.push()之類的方法調(diào)整數(shù)組大小,在未來的版本可能會支持長度修改。
10.For 循環(huán)
在上面我們提到過,函數(shù)中使用的數(shù)組是運行時在內(nèi)存中通過for循環(huán)實時構(gòu)建,而不是預(yù)先建立在存儲中的。for循環(huán)的語法在Solidity和JavaScript中類似。來看一個創(chuàng)建偶數(shù)數(shù)組的例子:
function getEvens() pure external returns(uint[]) {
uint[] memory evens = new uint[](5);
// 在新數(shù)組中記錄序列號
uint counter = 0;
// 在循環(huán)從1迭代到10:
for (uint i = 1; i <= 10; i++) {
// 如果 `i` 是偶數(shù)...
if (i % 2 == 0) {
// 把它加入偶數(shù)數(shù)組
evens[counter] = i;
//索引加一, 指向下一個空的‘even’
counter++;
}
}
return evens;
}
這個函數(shù)將返回一個形為 [2,4,6,8,10] 的數(shù)組。
總結(jié)
本章了解了智能合約一旦部署則不可修改,所以在編寫完智能合約后,對智能合約的審查是非常重要的事情,如果有任何差池,將不可逆轉(zhuǎn)且不再受控。不過也不要太過擔(dān)心,因為有像OpenZeppelin類似的合約庫,專門做智能合約安保和審查,可以減輕我們不少工作。
另外本課還有一個重要的信息就是:信息的修改存儲到區(qū)塊鏈上是需要花費gas的。這就要求我們在編寫智能合約時將一些中間信息盡量在本地內(nèi)存中處理,同時solidity也提供了像view
這樣的函數(shù)以節(jié)省gas。
從本課知道,編寫智能合約不是一件簡單的事情,每一行代碼都涉及到真金白銀,所以除了編寫者要細(xì)致外,還需要多人和專業(yè)的工具做檢查和測試,才能產(chǎn)出一份合格的智能合約。
系列文章:
【CryptoZombies|編寫區(qū)塊鏈游戲?qū)W智能合約】Lesson1: 搭建僵尸工廠
【CryptoZombies|編寫區(qū)塊鏈游戲?qū)W智能合約】Lesson2: 僵尸攻擊人類
【CryptoZombies|編寫區(qū)塊鏈游戲?qū)W智能合約】Lesson3: 搭建僵尸工廠
【CryptoZombies|編寫區(qū)塊鏈游戲?qū)W智能合約】Lesson4: 僵尸作戰(zhàn)系統(tǒng)
【CryptoZombies|編寫區(qū)塊鏈游戲?qū)W智能合約】Lesson5: ERC721標(biāo)準(zhǔn)和加密收藏品