
存儲概略
以太坊上的手續(xù)費昂貴是眾所周知的,只是隨著最近defi的火熱,它還是讓我們不禁發(fā)出又一聲感嘆。
我們隨機從uniswap中找一筆去除流動性交易,來感受下它的gas數(shù)據(jù):
Tx Fee: 0.09569346 Ether ($36.32)
Gas Price: 0.000000505 Ether (505 Gwei)
Gas Used: 189492
如此昂貴的手續(xù)費拉高了用戶的進(jìn)入門檻,大戶和巨鯨把持了網(wǎng)絡(luò)的流量,而小散用戶望而卻步,被隔離在了以太坊之外。
手續(xù)費的計算公式是Gas Fee = Gas Price * Gas Used,也就是說它的昂貴受兩種因素制約,一個是單位gas的價格,即GasPrice,一個是Gas Used,執(zhí)行合約代碼需要的gas數(shù)量。
GasPrice受市場供需影響,合約開發(fā)者無法左右,GasUsed卻是開發(fā)者可以實際掌控的,一個經(jīng)過良好優(yōu)化的合約代碼,可以在極低的gas消耗的情況下完成其功能,而這同樣降低了手續(xù)費,為小散用戶帶來曙光。
智能合約中的每一個操作,也就是每一行工作代碼都對應(yīng)著一定的gas數(shù)量,其中最為昂貴的操作大多與存儲相關(guān),存儲運用的越巧妙,代碼執(zhí)行時的gas消耗就會越低。
讓我們先來看看和存儲相關(guān)的操作的gas消耗情況:
create new slot: 20000 gas
change slot content for the first time: 5000 gas
change slot content again: 800 gas
load slot content: 800 gas
delete slot: refund 10000 gas
存儲之所以昂貴是因為它保存在區(qū)塊鏈的所有節(jié)點中,占用硬盤等資源;同時它作為world state的一部分,礦工在挖掘新的區(qū)塊時需要對包括存儲在內(nèi)的狀態(tài)進(jìn)行計算,計算出world state的merkle root來填寫到區(qū)塊頭當(dāng)中。
與之相對的是memory類型的存儲,這種類型在solidity編寫的合約中用memory關(guān)鍵字指出,它的操作是非常廉價的,這是因為它的生命周期僅限于合約執(zhí)行階段,執(zhí)行過后內(nèi)存就被回收,不涉及鏈上存儲和共識過程。
一個合約的狀態(tài)變量不能是memory屬性的,這是因為這些部署到以太坊上的合約是在不同的調(diào)用中共享的,合約的狀態(tài)變量需要在不同的調(diào)用中保持最新的狀態(tài),這類似分布式數(shù)據(jù)庫,用戶需要有能力改變這些狀態(tài)變量并且之后能夠再次訪問到它們。顯然memory形式的狀態(tài)變量做不到這一點。
存儲優(yōu)化
既然存儲中放置的是那些我們可以通過交易更改又必須能夠再次訪問到的數(shù)據(jù),同時它又是如此昂貴。那么我們想要為用戶節(jié)省交易的開銷,就必須針對這些數(shù)據(jù)做文章。
不是所有數(shù)據(jù)都需要在鏈上
鏈上存儲成本高昂,不要把冗余的信息,不必要的信息,可以鏈下計算獲得的信息放置在鏈上。比如鏈上存儲了所有的買單和賣單,卻沒有必要存儲買單的數(shù)量和賣單的數(shù)量,當(dāng)然能提供這些信息對DApp更友好。合理的選擇是在鏈下同步鏈上事件來獲取到這些信息,存儲在MySQL等傳統(tǒng)數(shù)據(jù)庫中來為DApp提供服務(wù)。
再比如訂單中存儲下單的時間,這類信息可以通過交易本身來獲取。
不是所有狀態(tài)變量都需要設(shè)為storage類型
合約的狀態(tài)變量中有一些可能需要在合約調(diào)用中被改寫,但是有一些僅作為全局常量信息使用。這種用途的變量我們通常把它定義為constant類型,對于一些只有在部署過程中才能確定的變量,我們可以將它定義為immutable類型,這種類型的變量可以在constructor中賦值,它的存儲位置是在合約的字節(jié)碼中。我們知道,對合約的code進(jìn)行讀取是非常廉價的。
舉個例子,在OneSwap的OneSwapPairProxy合約中,有如下代碼片段:
uint internal immutable _immuFactory;
uint internal immutable _immuMoneyToken;
uint internal immutable _immuStockToken;
uint internal immutable _immuOnes;
uint internal immutable _immuOther;
這些信息是在其被工廠合約創(chuàng)建時才能獲取到的,因此不能將他們標(biāo)記為constant,但是這些變量在其創(chuàng)建后的生命周期中是不需要改變的,所以將他們標(biāo)記為immutable是最為合適的,它們被存儲在合約code中,而不是鏈上存儲里。是不是一個省gas的好方法?趕緊把它運用到你的合約代碼中吧。
盡可能的縮短你的數(shù)據(jù)長度
由于evm的一次存儲操作是以256bit為單位進(jìn)行的,所以我們要盡可能的將一次存取的數(shù)據(jù)限制在256bit中,舉個例子:
下面這兩個數(shù)組中存儲的是OneSwap中的訂單:
uint[1<<22] private _sellOrders;
uint[1<<22] private _buyOrders;
一筆訂單包含了訂單的價格,訂單的數(shù)量,訂單的創(chuàng)建者,訂單的id。這些信息可能會按照下面的方式組織
struct Order {
address sender;
uint price;
uint amount;
uint nextID;
}
如果真是這樣的話,那么存儲和讀取這樣的一個結(jié)構(gòu)體簡直糟透了。它占用了四個存儲slot(每個slot是256bit),那么意味著對這樣組織的訂單的一次存儲和讀取需要進(jìn)行4次數(shù)據(jù)庫操作,具體點就是創(chuàng)建一筆新訂單需要80000gas,讀取一筆訂單需要3200gas。
那么讓我們來看看OneSwap的開發(fā)者是如何組織它們的訂單的:
// compress an order into a 256b integer
function _order2uint(Order memory order) internal pure returns (uint) {
uint n = uint(order.sender);
n = (n<<32) | order.price;
n = (n<<42) | order.amount;
n = (n<<22) | order.nextID;
return n;
}
// extract an order from a 256b integer
function _uint2order(uint n) internal pure returns (Order memory) {
Order memory order;
order.nextID = uint32(n & ((1<<22)-1));
n = n >> 22;
order.amount = uint64(n & ((1<<42)-1));
n = n >> 42;
order.price = uint32(n & ((1<<32)-1));
n = n >> 32;
order.sender = address(n);
return order;
}
他們將訂單的四個必要信息在保證其各自有效位數(shù)的前提下壓縮到了一個uint中,并且通過函數(shù)封裝對storage的操作,一次讀寫搞定訂單的創(chuàng)建和讀??!壓縮后可以節(jié)省gas消耗,但是可讀性和可編程性變差,因此開發(fā)者在內(nèi)存中定義了一個可讀性更強的Oder結(jié)構(gòu)體來映射該uint:
struct Order { //total 256 bits
address sender; //160 bits, sender creates this order
uint32 price; // 32-bit decimal floating point number
uint64 amount; // 42 bits are used, the stock amount to be sold or bought
uint32 nextID; // 22 bits are used
}
OneSwap開發(fā)者還是用了另外一種方式來壓縮存儲空間,讓我們看看下面這個LockSend合約中例子:
mapping(bytes32 => uint) public lockSendInfos;
用戶鎖定轉(zhuǎn)賬的信息存儲在上面的表中,這些信息包含轉(zhuǎn)賬的發(fā)起者,接收者,轉(zhuǎn)賬幣種,解鎖時間。用戶發(fā)起解鎖操作時需要校驗是否是與鎖定信息中相同的發(fā)送者,接收者,幣種和解鎖時間。如果我們使用結(jié)構(gòu)體去存儲這四個參數(shù)會占用4個storage slot。如何做才能既達(dá)到目的,又能節(jié)省存儲空間呢?
OneSwap開發(fā)者給出了下面的答案:
keccak256(abi.encodePacked(from, to, token, unlockTime))
首先將四個參數(shù)進(jìn)行abi編碼,然后對編碼后的字節(jié)序列做一次sha3哈希運算。將這個hash值作為訪問上面的lockSendInfos表的Key,當(dāng)用戶來解鎖時,用同樣的方式對用戶的輸入進(jìn)行哈希運算,如果二者一致,表明對應(yīng)的是同一筆鎖定轉(zhuǎn)賬信息。
cache你的存儲
如果你的合約中頻繁用到某些storage變量,那么多次的讀取和寫入勢必會帶來更高的gas消耗,cache你的變量到memory中,而不是每次都操作storage。
舉個例子,在合約執(zhí)行過程中,有些變量需要多次讀取和更改,比如訂單薄中的最優(yōu)買單id和賣單id,訂單薄中的token數(shù)量等,OneSwap開發(fā)者定義了一個Context結(jié)構(gòu)體,該結(jié)構(gòu)體中包含了合約執(zhí)行過程中的上下文,并且它始終位于memory中,讓我們先來看看它:
struct Context {
// this order is a limit order
bool isLimitOrder;
// the new order's id, it is only used when a limit order is not fully dealt
uint32 newOrderID;
// for buy-order, it's remained money amount; for sell-order, it's remained stock amount
uint remainAmount;
// it points to the first order in the opposite order book against current order
uint32 firstID;
// it points to the first order in the buy-order book
uint32 firstBuyID;
// it points to the first order in the sell-order book
uint32 firstSellID;
// the amount goes into the pool, for buy-order, it's money amount; for sell-order, it's stock amount
uint amountIntoPool;
// the total dealt money and stock in the order book
uint dealMoneyInBook;
uint dealStockInBook;
// cache these values from storage to memory
uint reserveMoney;
uint reserveStock;
uint bookedMoney;
uint bookedStock;
// reserveMoney or reserveStock is changed
bool reserveChanged;
// the taker has dealt in the orderbook
bool hasDealtInOrderBook;
// the current taker order
Order order;
// the following data come from proxy
uint64 stockUnit;
uint64 priceMul;
uint64 priceDiv;
address stockToken;
address moneyToken;
address ones;
address factory;
}
對于一個合約來講,它擁有非常多的字段,這些字段中會在一次合約調(diào)用中多次讀取和改寫,開發(fā)者在對外接口的開始部分利用storage和code初始化這些字段,在隨后的代碼執(zhí)行過程中不斷讀取和改寫這些字段,在合約接近結(jié)束的時候,再將這些字段中部分字段更新到storage中。
在合約開始的storage讀取和結(jié)束時的storage寫之間,所有操作都是在memory中進(jìn)行,這些操作是廉價的。
另外值得一提的是這種全局context的設(shè)計還解決了solidity編譯器stack too deep的問題,這種報錯出現(xiàn)在函數(shù)局部變量過多時,當(dāng)一個合約代碼復(fù)雜到像OneSwap這樣時,你就會發(fā)現(xiàn)你可用的局部變量不是那么充足了,這時候context是個不錯的選擇,它把信息存儲在內(nèi)存中,節(jié)省了棧上的空間。
Gas Token
利用刪除一個storage會返還5000gas的特性,一類名為gas token的特殊erc20幣種應(yīng)運而生,它在以太坊gas price較低時通過創(chuàng)建新的storage slot生成,在gas price較高時,通過釋放曾經(jīng)生成的storage slot來獲取返還的gas,這些gas可以抵扣用戶本身調(diào)用合約的gas消耗,達(dá)到在gas price價格高昂時降低手續(xù)費的目的。
OneSwap并沒有直接使用Gas Token。但是,Maker在下單的時候,需要支付20000 Gas創(chuàng)建一個新的存儲slot來保存自己的訂單;而Taker在吃單的時候,刪除了這個存儲Slot,獲得了10000 Gas的返還。這等效于Maker為Taker準(zhǔn)備了一個Gas Token。因此,即使Taker連續(xù)吃掉了好幾個Maker的單,Gas消耗也能控制在較低的水平。
利用Gas限制存儲操作并不可靠
存儲本身是安全可靠的,但是有時候由于evm gas規(guī)則的改變等,讓一些以來storage gas的合約操作變得具有不確定性。
我們來看OneSwap當(dāng)中的一個例子:
to.call{value: value, gas: 9000}(new bytes(0))
該代碼向to地址轉(zhuǎn)賬value數(shù)量的eth,這次調(diào)用只給了9000的gas,也就是說被調(diào)用地址如果是合約的話,那么它的執(zhí)行邏輯最多只能消耗9000gas,否則會出現(xiàn)out of gas錯誤。開發(fā)者的目的是限制to地址只能進(jìn)行一些storage讀取,更新和計算操作,不能進(jìn)行storage的創(chuàng)建。
我們假如以太坊規(guī)則更改,一次storage更改需要10000gas,那么9000gas就不夠用了,被調(diào)用地址會顯示out of gas錯誤。如果不巧的是調(diào)用者合約要判斷這筆轉(zhuǎn)賬調(diào)用的返回值為true時才繼續(xù)后面的邏輯的話,那么它將無法繼續(xù)工作。
這種問題和風(fēng)險需要合約開發(fā)者結(jié)合自身合約邏輯仔細(xì)考慮,最好留出升級的機制,當(dāng)以太坊的規(guī)則改變后,合約可以做出相應(yīng)的修改。
總結(jié)
存儲操作是合約調(diào)用消耗的gas中的大頭,降低交易成本的重中之重是要合理的安排和操作合約中的存儲,你需要仔細(xì)的甄選出那些真正需要上鏈的數(shù)據(jù),然后盡可能合理的壓縮他們到較少的storage slot中,如果你的程序中需要頻繁的訪問storage變量,那么cache他們到內(nèi)存中是不錯的選擇。最后,不要依賴storage操作的gas來設(shè)計你的程序,至少要了解到這樣做可能帶來的風(fēng)險。
原文:《OneSwap Series 6 — Expensive Storage》
鏈接:https://medium.com/@OneSwap/oneswap-series-6-expensive-storage-60f5857d58fc
翻譯:OneSwap中文社區(qū)