LOADING...
LOADING...
LOADING...
当前位置: 玩币族首页 > 新闻观点 > CoinEx Smart Chain 合约Gas计费解析(一)

CoinEx Smart Chain 合约Gas计费解析(一)

2020-05-14 CoinEx公链Talk 来源:火星财经



CoinEx Smart Chain支持WebAssembly(简称Wasm)作为底层的虚拟机,用户使用Rust/C++/AssemblyScript编写的智能合约代码会被编译为Wasm二进制格式存储在链上。为了避免智能合约无限制地消耗计算和存储资源,我们引入了Gas计费规则来限制智能合约的资源消耗。每一次智能合约调用都需要用户指定他可以为本次调用所提供的Gas数量以及支付的费用。当Gas在合约执行过程中被耗尽时,合约的执行被认为是失败的,相应的状态变化会被丢弃。同时,如果合约执行完毕后仍有Gas剩余,则可以将对应的fees返还给用户(具体实现中也可以选择不返还)。而由于Wasm规范本身并不包括Gas计费,我们需要手动地对已经编译好的Wasm模块插入相应的计费指令,同时尽量减少对合约执行的性能影响。

在CoinEx Smart Chain当中所实现的Gas计费功能,有两大特色:

第一大特色是VM-Side Metering。Metering方案首先要考虑的问题就是在哪里维护Gas的剩余量。在具体的实现中,既可以选择在Host中维护,也可以选择在VM中维护。如果在Host当中维护,则需要频繁在VM中进行Host函数调用,以便及时更新当前的Gas消耗,性能损失较大。如果在VM中维护,则只需要在跨合约调用时,从Host一侧调用VM暴露出的外部函数来获取和更新当前Gas剩余量,性能损失相对较小。但是,在VM当中维护Gas剩余量,设计的复杂度会更高一些,下文我们将详细介绍CoinEx Smart Chain中Metering的设计方案,以及我们是如何做到VM-Side Metering的。

第二大特色是同时支持两种计费粒度:Basic-block-based & Super-block-based 。按照计费粒度的大小,可以将计费策略分为Instruction-based、Basic-block-based和Super-block-based。Instruction-based Metering为每一条指令都增加计费逻辑,其优点显而易见:它可以最及时地探查到Gas超上限的情形,但同时它的性能损失太大。Basic-block-based Metering为每个不被跳转指令中断的指令块(即basic block)增加一处计费逻辑,相比Instruction-based Metering而言,它的性能损失小了许多。而Super-block-based Metering,只对那些有可能造成PC向减小方向跳转、函数调用和返回的相关指令(即与Loop配套的Branch指令、Return指令)插入计费逻辑,它的性能损失最小。诚然,与Basic-block-based Metering相比,Super-block-based Metering不能做到精确地为每个basic-block计费,但这并不妨碍它达到限制智能合约资源无限制消耗的目的。因此,在CoinEx Smart Chain的实现中,同时支持Basic-block-based & Super-block-based两种计费策略。

上述所有内容计划分为上下两篇文章来介绍,本篇文章着重于介绍CoinEx Smart Chain中Basic-block-based & Super-block-based两种Metering策略的实现思路。后续文章中我们会给出基于这两种Metering 策略的Benchmark对比。

GasLimit as a global variable

要做到在VM-Side进行Gas计费,我们需要在Wasm模块中引入一个global变量(记为GasLimit)来记录当前可用的Gas数量。在用户发起一笔合约调用的交易时,会根据交易中附带的Gas来初始化该变量。随着Wasm字节码的执行,会在一些特定位置对GasLimit变量进行更新,并判断Gas是否超过限制,如果超过,则立即终止合约的执行。

由于Wasm合约在运行时不仅需要VM的支持,还需要一些外部host函数的支持。为了使host环境能够即时地获取和更新剩余的Gas数量,我们需要在编译好的Wasm模块中导出两个函数:getGasLimit&setGasLimit。在初始化一个虚拟机实例时,需要调用setGasLimit来初始化合约执行的GasLimit。在合约执行结束时,host环境需要调用getGasLimit来获取当前的Gas剩余。同时,在当前合约调用了另外一个合约时,也需要先获得执行到当前位置的Gas剩余,再根据该值来初始化下一个合约的GasLimit。相反,在被调用方合约执行结束后,也需要在调用方合约实例里更新当前的Gas剩余。这些都需要getGasLimit&setGasLimit这两个导出函数来配合完成。

+-------------+ Gas +----------+ +-------------------+ | | -----------> | | Initialize | | |User/Contract| | Contract | -----------> | VM Instance | | | <----------- | | | | +-------------+ GasLeft +--|---|---+ 1. |globalVar:GasLimit | | +-----------------> |ExpFunc:setGasLimit| +---------------------> |ExpFunc:getGasLimit| 2. +-------------------+ 1. 初始化VM Instance: vm.GasLimit=vm.setGasLimit(Gas) 2. VM执行完毕: GasLeft=vm.getGasLimit() 复制代码

图1:用户/合约发起合约调用时,GasLimit变量的初始化和更新。

Branch operation triggers a new metering Unit

在上一小节中,我们提到会在Wasm模块的特定位置插入更新GasLimit的指令,这些特定位置的选取与Basic-block-based & Super-block-based两种不同的Metering 策略相关。我们首先介绍Basic-block-based Metering 策略。

Basic-block-based Metering

假设我们对一个Wasm模块的函数从头开始扫描,并且每一条Wasm指令的Gas消耗为1。为了实现对指令的精确计费,每当我们越过一条指令,就要在累计的Gas消耗上加1。这时候,如果遇到了一个if指令,接下来的执行路径就会分叉。由于我们无法判断在执行时真正会走那条路径,我们需要分别在分叉处和这两条路径的结尾处分别进行全局变量GasLimit更新,因此这三处位置就属于上面所说的特定位置。也就是说,在相邻的两个特定位置之间,指令序列的执行路径是唯一确定的,而在当前位置之后,执行路径就就会分叉。Wasm中提供的指令满足上述性质的有:if/else/end/br/br_if/br_table/loop。另外,为了在函数运行结束时对Gas进行更新,需要将return指令也加进来。

再者,如果在该function内部调用了另外一个函数,则需要在调用之前更新目前为止累计的Gas消耗,这是为了防止一个除了调用自身之外什么都不做的恶意函数对资源的消耗。Wasm中函数调用的指令有:call/call_indirect。

除此之外,需要特殊处理的指令还有memory.grow, 为了防止合约向虚拟机请求巨大的内存资源,这一操作会被提前收费,同时该指令的Gas消耗与请求分配的内存页大小成正比,例如比例系数为1000时,每申请一页内存,就需要消耗1*1000的Gas。

我们将这些特定位置对应的指令称为branch operations,它包含以下Wasm指令:

var branchingOps = []byte{ operators.End, operators.GrowMemory, operators.Br, operators.BrIf, operators.BrTable, operators.If, operators.Else, operators.Return, operators.Loop, operators.Call, operators.CallIndirect, } 复制代码

最终,Wasm代码将会被以上指令分成若干段,每一段都会在结尾处更新当前段执行的Gas消耗,并且在记录的GasLimit小于零时终止合约的执行。

Super-block-based Metering

与Basic-block-based Metering相比, Super-block-based Metering 并不力求进行精准地Gas计费,只在可能会引起大量/无限资源消耗的位置前更新剩余的Gas。也就是说,只有会跳转到Loop起始处的Br/BrIf/BrTable指令、GrowMemory指令和Return指令的出现会引起Gas的更新。这样可以在防止智能合约无限制地消耗系统资源的前提下,将Metering所带来的性能损耗降到最低。

Super-block-based Metering 的branch operations包含以下Wasm指令:

var branchingOps = []byte{ operators.GrowMemory, operators.Br, operators.BrIf, operators.BrTable, operators.Return, } 复制代码

对于跳转到Loop起始处的指令,收取的Gas Fee正比于Loop中所有指令的数目;对于Return指令,收取的Gas Fee正比于函数中所包含的指令数。

Gas consume for external functions

由于用户编写的合约需要从host环境中获得一些信息,如函数执行的参数,链高度等,CoinEx Smart Chain同时还提供了一些外部函数供Wasm虚拟机调用。相比Wasm指令的执行而言,外部函数往往会消耗更多的资源,因此如何对外部函数进行计费尤为重要。一个最直接的做法是在外部函数结尾处通过调用Wasm模块所导出的getGasLimit&setGasLimit方法来更新Gas消耗。这种做法带来了从Wasm虚拟机到host环境,又从host环境到Wasm虚拟机的来回切换,造成的性能损耗较大。同样地,我们仍希望在虚拟机内部执行外部函数的Gas消耗。

我们的做法是,在扫描到Wasm的call指令时,根据call指令的参数funcIndex判断被调用函数是内部函数还是外部函数(Wasm虚拟机规范中函数Index排序是从外部函数到内部函数来增长的),如果是外部函数的话,则根据预先设置的外部函数Gas消耗表来获得本次调用需要消耗的Gas数量,并对GasLimit进行更新。如果是内部函数的话,则无需任何操作。但如果是call_indirect指令的话,情况有些不同。

首先我们来大致了解一下call_indirect指令的用法:call_indirect指令的Opcode为0x11,后跟一个被调用函数的type索引作为立即数。在运行时,会从栈上弹出一个i32类型的值作为函数的索引从Wasm模块的tablesection来拿到被调函数的索引,并比较真实调用的函数签名与type索引指向的函数签名是否一致。由于真实被调用的函数无法通过静态分析来确定,因此,对于call_indirect指令,我们无法事先判断它调用的是内部函数还是外部函数,这样也就无法使用针对call指令类似的做法来处理外部函数的Gas消耗。但由于call_indirect指令主要用来支持一些高级语言如C++/Rust中dynamic dispatch的功能:函数指针、虚函数、trait objects等,我们对该指令加了一条限制:不允许call_indirect指令调用外部函数,我们会在Wasm二进制文件中插入这样的检查。需要指出的是,这样做既不影响合约的功能性,同时对Gas的处理也更为简单。在这条限制下,我们无需对call_indirect做额外的Metering处理。

Conclusion

至此,我们完整介绍了CoinEx Smart Chain上对Wasm合约进行Gas计费的原理和两种实现策略:Basic-block-based Metering & Super-block-based Metering。两者在实现上仅在更新Gas消耗逻辑的插入位置有所区别,但Basic-block-based Metering能够提供精准地Gas计费,而Super-block-based Metering的性能优势则更明显一些。下一篇,我们将给出基于这两种实现策略的Benchmark对比。

本文由CoinEx Chain开发团队成员贾音撰写。CoinEx Chain是全球首条基于Tendermint共识协议和Cosmos SDK开发的DEX专用公链,借助IBC来实现DEX公链、智能合约链、隐私链三条链合一的方式去解决可扩展性(Scalability)、去中心化(Decentralization)、安全性(security)区块链不可能三角的问题,能够高性能的支持数字资产的交易以及基于智能合约的Defi应用。

本文来源:CoinEx公链Talk
原文标题:CoinEx Smart Chain 合约Gas计费解析(一)

—-

编译者/作者:CoinEx公链Talk

玩币族申明:玩币族作为开放的资讯翻译/分享平台,所提供的所有资讯仅代表作者个人观点,与玩币族平台立场无关,且不构成任何投资理财建议。文章版权归原作者所有。

LOADING...
LOADING...