基于以太坊的开发,教你如何节省手续费

Posted jking 景

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了基于以太坊的开发,教你如何节省手续费相关的知识,希望对你有一定的参考价值。

以太坊手续费之殇

以太坊本质上是一个虚拟机,交易的gas成本主要由虚拟机的运行成本组成。当你发送 Token或者执行智能合约时,以太坊在处理这笔交易的过程中需要进行计算,会根据复杂度消耗一笔gas费(ETH)。

目前以太坊上的大部分应用都是以智能合约的形态在运行,而以太坊智能合约采用solidity语言编写,因而在编写智能合约时,不仅要考虑安全,还要考虑语言的优化,以便合约应用更高效便宜,一方面也为自己的用户节省应用手续费。

如何节省手续费

设计合约

因为合约执行的复杂度和计算量直接与执行消耗的gas相关,所以在合约设计,创建合约时,最好对需要上链的数据结构进行优化设计,只将必要的数据上链存储,比如基于ERC-721的NFT标准,NFT合约只要求将NFT的唯一TokenId上链存储,TokenId与对应标记物品的关系可以用URI的方式指向传统互联网网络上的一个资源;

比如,我们设计一个基于鱼类的NFT合约,按照ERC-721标准,我们只需要实现类似如下的合约即可

Contract MyFish { 
    // Mapping from token ID to owner address 
    mapping(uint256 => address) private _owners; 
 
    // ....... 
 
    function tokenURI(uint256 tokenId) public view returns (string memory) { 
       // ...... 
    } 
    //...... 
} 
 

以上合约中,我们只将NFT唯一TokenId在以太坊上,并通过URI标记对应物品

假如我们将鱼类的其它属性也上链存储,合约设计成如下形式

Contract MyFish { 
 
    struct Fish { 
        uint length; 
        uint weight; 
        uint age; 
    } 
    // Mapping from token ID to owner address 
    mapping(uint256 => address) private _owners; 
 
    // Mapping from token ID to attributes 
    mapping(uint256 => Fish) private _fishes; 
    // ....... 
 
    function mintFish(uint256 tokenId, uint _length, uint _weight, uint _age) public returns (uint256) { 
       // ...... 
       Fish memory fish = Fish( 
          _length, 
          _weight, 
          _age 
       ) 
       // ...... 
    } 
 
    function tokenAttr(uint256 tokenId) public view returns (uint _length, uint _weight, uint _age) { 
       // ...... 
    } 
    //...... 
} 
 

按如上合约的方式,我们将每条鱼的更多属性也上链存储,虽然这样更符合区块链去中心化的目的之一,保证数据不可更改,但是这样会导致每次铸造NFT时要在以太坊上存储更多内容,以及涉及更多转移、计算流程等时合约执行复杂的更高,必然带来更多的手续费消耗。

虽然上面的方案一存在一定的中心化风险,实际上这个问题也是可以有更多其它方案来解决的;比如我们将更多的属性类的数据放到其它手续费更低的区块链系统中,比如我们可以存储到Filecoin链上,越来越多的NFT项目都在采用这类方案;

以及目前发展越来越完善的以太坊Layer 2扩容,一方面的目的也是解决这类问题,将部分数据的存储迁移到专门的存储模块上,以降低以太坊操作的手续费。

避免将以太坊合约用作数据存储。

Opensea中的NFT拍卖合约,NFT在上架拍卖时,并没有将该NFT的拍卖信息上链,在拍卖完成或者下架时,才将该次拍卖的的hash数据上链,确保不会在合约中操作无效的拍卖订单,这样也是一种降低存储、降低手续费的方案。

存储

上面的合约设计中提到了避免直接将以太坊区块链当作存储平台,除此之外,在合约代码执行中,也有一些技巧避免过度的手续费消耗,

比如,如下的两端合约代码

uint256 public count; 
// ... 
for (uint256 i = 0; i < 10; ++i) { 
  // ...   
  ++count; 
} 

更节省手续费的写法

for (uint256 i = 0; i < 10; ++i) { 
  // ... 
} 
count += 10; 

第二种方式组织的代码,可以避免频繁的修改合约中的存储块变量,降低手续费。

避免重复写入,最好一次在最后尽可能多地写入到存储变量。

变量排序对gas的影响

由于EVM操作都是以32字节为单位执行的,因此编译器将尝试将变量打包成32字节集进行访问,以减少访问时间。 但是,编译器不够智能,无法自动优化变量分组。它将静态大小的变量分组为32个字节的组。例如:

contract MyContract { 
  uint64 public a; 
  uint64 public b; 
  uint64 public c; 
  uint64 public d; 
function test() { 
    a = 1; 
    b = 2; 
    c = 3; 
    d = 4; 
  } 
} 

执行test()时,看起来已经存储了四个变量。由于这四个变量之和恰好是32个字节,因此实际执行了一个SSTORE。这只需要20,000 gas。

再看下一个例子:

contract MyContract { 
  uint64 public a; 
  uint64 public b; 
  byte e; 
  uint64 public c; 
  uint64 public d; 
function test() { 
    a = 1; 
    b = 2; 
    c = 3; 
    d = 4; 
  } 
} 

中间插入了另一个变数,结果造成a,b,e和c会被分为一组,d独立为一组。同样的test()造成两次写入,消耗40000 gas。

最后再看一个例子:

contract MyContract { 
  uint64 public a; 
  uint64 public b; 
  uint64 public c; 
  uint64 public d; 
function test() { 
    a = 1; 
    b = 2; 
    // ... do something 
    c = 3; 
    d = 4; 
  } 
} 

这与第一个例子的区别在于,在存储a和b之后,完成了其他事情,最后存储了c和d。结果这次将导致两次写入。因为当执行“执行某事”时,编译器确定打包操作已结束,然后发送写入。但是,由于第二次写入是同一组数据,因此认为它是被修改的。将消耗总共25,000个气体。

建议: 根据上述原则,我们可以很容易地知道如何处理它。

正确的排序和分组 将数据大小分组为32个字节,并将通常同时更新的变量放在一起。 不好的代码例子:

contract MyContract { 
  uint128 public hp; 
  uint128 public maxHp; 
  uint32 level; 
  uint128 public mp; 
  uint128 public maxMp; 
} 
 

好的例子:

contract MyContract { 
  uint128 public hp; 
  uint128 public mp; 
  uint128 public maxHp; 
  uint128 public maxMp; 
  uint32 level; 
} 

这里我们假设hp和mp更频繁地更新,并且maxHp和maxMp更频繁地一起更新。

尽量一次访问 不好的代码例子:

function test() { 
    hp = 1; 
    // ... do something 
    mp = 2; 
  } 

好的例子:

function test() { 
    // ... do something 
    hp = 1; 
    mp = 2; 
  } 

转账

Call, send 和transfer 函数对应于CALL指令。基本消耗是7,400 gas。事实上,消费将近7,600 gas。值得注意的是,如果转账到一个从未见过的地址,将额外增加25,000个gas。

// 没有额外的消耗样例 
function withdraw(uint256 amount){  
  msg.sender.transfer(amount); 
} 
 
// 可能会有额外的消耗样例(receiver参数未被使用,多余参数) 
function withdrawTo(uint256 amount, address receiver) { 
  receiver.transfer(amount); 
} 
 

调用合约函数的成本优化

当调用合约额的功能时,为了执行功能,它需要gas。因此,优化使用较少gas的功能非常重要。在考虑每个合约时时,可以采用多种不同的方式。这里有一些可能在执行过程中节省gas的方法。

减少昂贵的操作

昂贵的操作是指一些需要更多gas值的操作码,例如SSTORE。以下是一些减少昂贵操作的方法。

使用短路规则

操作符 || 和&&适用常见的短路规则。这意味着在表达式f(x)|| g(y)中,如果f(x)的计算结果为真,即使它有副作用,也不会评估g(y)。

因此,如果逻辑操作包括昂贵的操作和低成本操作,那么以昂贵的操作可以短路的方式安排将在一些执行中减少gas。

如果f(x)是便宜的并且g(y)是昂贵的,逻辑运算代码(便宜的放在前面):

OR : f(x) || g(y)
AND: f(x) && g(y)
如果短路,将节省更多的气体。

f(x)与g(y)安排AND操作相比,如果返回错误的概率要高得多,f(x) && g(y)可能会导致通过短路节省更多的气体。

f(x)与g(y)安排OR运算相比,如果返回真值的概率要高得多,f(x) || g(y)可能会导致通过短路节省更多气体。

删除无用的代码可以在执行时节省gas

删除无用的代码即使在执行函数时也会节省gas。

在实现简单功能时不使用第三方库对于简单的应用场景来说更便宜

调用第三方库以获得简单的用法可能代价高昂。

因为引入库中的部分功能可能是不必要的,这些会显著增加合约的部署成本。

以上是关于基于以太坊的开发,教你如何节省手续费的主要内容,如果未能解决你的问题,请参考以下文章

基于以太坊的智能合约开发教程Solidity 合约的销毁

EIP-1559上线后的效果如何?

什么是以太币/以太坊ETH?

基于以太坊的智能合约开发教程 Solidity 全局变量

教你吃透以太坊的测试网络

基于 Ruby-on-Rails 开发以太坊的应用