OneSwap系列六之昂贵的存储

.png

存储概略

以太坊上的手续费昂贵是众所周知的,只是随着最近defi的火热,它还是让我们不禁发出又一声感叹。

我们随机从uniswap中找一笔去除流动性交易,来感受下它的gas数据:

Tx Fee:    0.09569346  Ether ($36.32)
Gas Price: 0.000000505 Ether (505 Gwei)
Gas Used:  189492

如此昂贵的手续费拉高了用户的进入门槛,大户和巨鲸把持了网络的流量,而小散用户望而却步,被隔离在了以太坊之外。

手续费的计算公式是Gas Fee = Gas Price * Gas Used,也就是说它的昂贵受两种因素制约,一个是单位gas的价格,即GasPrice,一个是Gas Used,执行合约代码需要的gas数量。

GasPrice受市场供需影响,合约开发者无法左右,GasUsed却是开发者可以实际掌控的,一个经过良好优化的合约代码,可以在极低的gas消耗的情况下完成其功能,而这同样降低了手续费,为小散用户带来曙光。

智能合约中的每一个操作,也就是每一行工作代码都对应着一定的gas数量,其中最为昂贵的操作大多与存储相关,存储运用的越巧妙,代码执行时的gas消耗就会越低。

让我们先来看看和存储相关的操作的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

存储之所以昂贵是因为它保存在区块链的所有节点中,占用硬盘等资源;同时它作为world state的一部分,矿工在挖掘新的区块时需要对包括存储在内的状态进行计算,计算出world state的merkle root来填写到区块头当中。

与之相对的是memory类型的存储,这种类型在solidity编写的合约中用memory关键字指出,它的操作是非常廉价的,这是因为它的生命周期仅限于合约执行阶段,执行过后内存就被回收,不涉及链上存储和共识过程。

一个合约的状态变量不能是memory属性的,这是因为这些部署到以太坊上的合约是在不同的调用中共享的,合约的状态变量需要在不同的调用中保持最新的状态,这类似分布式数据库,用户需要有能力改变这些状态变量并且之后能够再次访问到它们。显然memory形式的状态变量做不到这一点。

存储优化

既然存储中放置的是那些我们可以通过交易更改又必须能够再次访问到的数据,同时它又是如此昂贵。那么我们想要为用户节省交易的开销,就必须针对这些数据做文章。

不是所有数据都需要在链上

链上存储成本高昂,不要把冗余的信息,不必要的信息,可以链下计算获得的信息放置在链上。比如链上存储了所有的买单和卖单,却没有必要存储买单的数量和卖单的数量,当然能提供这些信息对DApp更友好。合理的选择是在链下同步链上事件来获取到这些信息,存储在MySQL等传统数据库中来为DApp提供服务。

再比如订单中存储下单的时间,这类信息可以通过交易本身来获取。

不是所有状态变量都需要设为storage类型

合约的状态变量中有一些可能需要在合约调用中被改写,但是有一些仅作为全局常量信息使用。这种用途的变量我们通常把它定义为constant类型,对于一些只有在部署过程中才能确定的变量,我们可以将它定义为immutable类型,这种类型的变量可以在constructor中赋值,它的存储位置是在合约的字节码中。我们知道,对合约的code进行读取是非常廉价的。

举个例子,在OneSwap的OneSwapPairProxy合约中,有如下代码片段:

uint internal immutable _immuFactory;
uint internal immutable _immuMoneyToken;
uint internal immutable _immuStockToken;
uint internal immutable _immuOnes;
uint internal immutable _immuOther;

这些信息是在其被工厂合约创建时才能获取到的,因此不能将他们标记为constant,但是这些变量在其创建后的生命周期中是不需要改变的,所以将他们标记为immutable是最为合适的,它们被存储在合约code中,而不是链上存储里。是不是一个省gas的好方法?赶紧把它运用到你的合约代码中吧。

尽可能的缩短你的数据长度

由于evm的一次存储操作是以256bit为单位进行的,所以我们要尽可能的将一次存取的数据限制在256bit中,举个例子:

下面这两个数组中存储的是OneSwap中的订单:

uint[1<<22] private _sellOrders;
uint[1<<22] private _buyOrders;

一笔订单包含了订单的价格,订单的数量,订单的创建者,订单的id。这些信息可能会按照下面的方式组织

struct Order {
    address sender; 
    uint price; 
    uint amount;
    uint nextID;
}

如果真是这样的话,那么存储和读取这样的一个结构体简直糟透了。它占用了四个存储slot(每个slot是256bit),那么意味着对这样组织的订单的一次存储和读取需要进行4次数据库操作,具体点就是创建一笔新订单需要80000gas,读取一笔订单需要3200gas。

那么让我们来看看OneSwap的开发者是如何组织它们的订单的:

// 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;
    }

他们将订单的四个必要信息在保证其各自有效位数的前提下压缩到了一个uint中,并且通过函数封装对storage的操作,一次读写搞定订单的创建和读取!压缩后可以节省gas消耗,但是可读性和可编程性变差,因此开发者在内存中定义了一个可读性更强的Oder结构体来映射该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开发者还是用了另外一种方式来压缩存储空间,让我们看看下面这个LockSend合约中例子:

mapping(bytes32 => uint) public lockSendInfos;

用户锁定转账的信息存储在上面的表中,这些信息包含转账的发起者,接收者,转账币种,解锁时间。用户发起解锁操作时需要校验是否是与锁定信息中相同的发送者,接收者,币种和解锁时间。如果我们使用结构体去存储这四个参数会占用4个storage slot。如何做才能既达到目的,又能节省存储空间呢?

OneSwap开发者给出了下面的答案:

keccak256(abi.encodePacked(from, to, token, unlockTime))

首先将四个参数进行abi编码,然后对编码后的字节序列做一次sha3哈希运算。将这个hash值作为访问上面的lockSendInfos表的Key,当用户来解锁时,用同样的方式对用户的输入进行哈希运算,如果二者一致,表明对应的是同一笔锁定转账信息。

cache你的存储

如果你的合约中频繁用到某些storage变量,那么多次的读取和写入势必会带来更高的gas消耗,cache你的变量到memory中,而不是每次都操作storage。

举个例子,在合约执行过程中,有些变量需要多次读取和更改,比如订单薄中的最优买单id和卖单id,订单薄中的token数量等,OneSwap开发者定义了一个Context结构体,该结构体中包含了合约执行过程中的上下文,并且它始终位于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;
}

对于一个合约来讲,它拥有非常多的字段,这些字段中会在一次合约调用中多次读取和改写,开发者在对外接口的开始部分利用storage和code初始化这些字段,在随后的代码执行过程中不断读取和改写这些字段,在合约接近结束的时候,再将这些字段中部分字段更新到storage中。

在合约开始的storage读取和结束时的storage写之间,所有操作都是在memory中进行,这些操作是廉价的。

另外值得一提的是这种全局context的设计还解决了solidity编译器stack too deep的问题,这种报错出现在函数局部变量过多时,当一个合约代码复杂到像OneSwap这样时,你就会发现你可用的局部变量不是那么充足了,这时候context是个不错的选择,它把信息存储在内存中,节省了栈上的空间。

Gas Token

利用删除一个storage会返还5000gas的特性,一类名为gas token的特殊erc20币种应运而生,它在以太坊gas price较低时通过创建新的storage slot生成,在gas price较高时,通过释放曾经生成的storage slot来获取返还的gas,这些gas可以抵扣用户本身调用合约的gas消耗,达到在gas price价格高昂时降低手续费的目的。

OneSwap并没有直接使用Gas Token。但是,Maker在下单的时候,需要支付20000 Gas创建一个新的存储slot来保存自己的订单;而Taker在吃单的时候,删除了这个存储Slot,获得了10000 Gas的返还。这等效于Maker为Taker准备了一个Gas Token。因此,即使Taker连续吃掉了好几个Maker的单,Gas消耗也能控制在较低的水平。

利用Gas限制存储操作并不可靠

存储本身是安全可靠的,但是有时候由于evm gas规则的改变等,让一些以来storage gas的合约操作变得具有不确定性。

我们来看OneSwap当中的一个例子:

to.call{value: value, gas: 9000}(new bytes(0))

该代码向to地址转账value数量的eth,这次调用只给了9000的gas,也就是说被调用地址如果是合约的话,那么它的执行逻辑最多只能消耗9000gas,否则会出现out of gas错误。开发者的目的是限制to地址只能进行一些storage读取,更新和计算操作,不能进行storage的创建。

我们假如以太坊规则更改,一次storage更改需要10000gas,那么9000gas就不够用了,被调用地址会显示out of gas错误。如果不巧的是调用者合约要判断这笔转账调用的返回值为true时才继续后面的逻辑的话,那么它将无法继续工作。

这种问题和风险需要合约开发者结合自身合约逻辑仔细考虑,最好留出升级的机制,当以太坊的规则改变后,合约可以做出相应的修改。

总结

存储操作是合约调用消耗的gas中的大头,降低交易成本的重中之重是要合理的安排和操作合约中的存储,你需要仔细的甄选出那些真正需要上链的数据,然后尽可能合理的压缩他们到较少的storage slot中,如果你的程序中需要频繁的访问storage变量,那么cache他们到内存中是不错的选择。最后,不要依赖storage操作的gas来设计你的程序,至少要了解到这样做可能带来的风险。

原文:《OneSwap Series 6 — Expensive Storage》
链接:https://medium.com/@OneSwap/oneswap-series-6-expensive-storage-60f5857d58fc

翻译:OneSwap中文社区

你可能感兴趣的:(OneSwap系列六之昂贵的存储)