Jenner's Blog

不变秃,也要变强!

0%

合约模块解析

分析基于 substrate 的 2021年3月11日 b24c43af 版本。本文将介绍合约模块的合约调用 的基本逻辑,并且会介绍关于合约存储收费的设计。

调用合约

合约调用的大致流程,下图中只显示了ExecutionContext::call的内部逻辑,ExecutionContext::instantiate其实也是类似的。主要在于理解链的runtime层与合约WASM代码是怎么交互的。
call

手续费

在Substrate的设计中,调用交易需要根据Weight收取交易手续费。contracts模块定义了几个主要执行接口:callinstantiate_with_codeinstantiate。其中,instantiate_with_code接口包含了早期版本中的put_codeinstantiate,是为了对合约代码存储进行收费。

在调用以上三个接口时,除了收取由Weight固定的交易手续费之外,还根据gas_limit收取gas费用。gas费用被转换成Weight,在调用接口时,按照Weight的方式统一收取。

根据gas_limit收取的手续费存储在gas_meter中,gas_meter可以被称为gas管家。在合约执行过程中,每次调用host_function会进行相应的gas费用收取,就是从gas_meter提取的。执行完所有步骤后,由gas_meter中记录的gas_left进行手续费的返还。

合约执行上下文

合约只向pallet::contracts模块层(以下简称为contracts模块)暴露两个执行入口:ConstructorCall。在contracts模块中,Constructor被封装在ExecutionContext::instantiate中,Call被封装在ExecutionContext::call中。
contracts模块中的三个接口调用ExecutionContext的两个封装后的执行入口,调用关系如下图所示。
ExecutionContext

ExecutionContext是合约执行上下文,合约调用另一个合约的情况下,直接调用封装在ExecutionContext中的接口,不需要从最外层的三个接口调用,因此不用再收取contracts模块中定义的固定Weight的手续费。

call

ExecutionContext::call中,需要根据code_hash加载合约代码,生成一个wasm module作为executable。然后根据dest获取对应的合约实例,并对该实例进行存储费用计算。该部分涉及费用计算,因此在合约存储收费中介绍。

关于load加载合约代码:它会比较 schedule 的版本(instantiate_with_code在上传合约代码时,写入一个是原始代码处理后的 PrefabWasmModule,包含了schedule_versionoriginal_codecode_hashrefcount等)。如果当前版本大于PrefabWasmModule中的版本, 那么需要重新预处理,否则直接返回已经存储的 PrefabWasmModule

schedule内主要定义合约命令的weight和host_fn的weight,影响了手续费的计算。

嵌套执行上下文

在执行具体业务逻辑之前,需要把执行上下文包装在新的ExecutionContext中,并且在最后能截获当前合约执行的结果。

ExecutionContext执行

NestedExecutionContext环境中,执行以下的业务逻辑。

如果调用时传入的参数value不为0的话,会调用transfervalue传给dest账户(不一定是合约账户)。

executable执行合约。通过ExportedFunction::Call(合约暴露的接口)执行合约代码内的相应函数,传入input_data,其中包含了selector与合约函数对应的传参。

合约Runtime与链Runtime交互

在执行合约时,需要开辟一段内存空间,将合约实例加载进去,形成了合约实例的Runtime环境,把它称为合约Runtime。

两个Runtime像是运行在两个不同的沙盒中,互不感知。他们之间的交互就需要一个第三方介入,它就是Host。Host是链固定的功能部分,采用原生执行的方式。对于host_fn这些接口,在合约编写框架层(例如ink!)和contracts模块需要制定一致的协议。Substrate上的host_fn代码在frame/contracts/src/wasm/runtime.rs中的define_env!宏中。

更具体的机制介绍可以查看赖智超的substrate的合约机制分析,在文章的Wasm合约的执行Sandbox机制有相关介绍。

对于合约调用另一个合约的嵌套调用情况,实际上也是两个合约Runtime交互的过程。

执行结果

根据执行的结果,调用commit_transaction() 或者 rollback_transaction()。注意,如果是gas耗尽导致的Revert,只会回滚当前合约的状态,而更高层次的合约状态无法回滚。

最后就是返回剩余手续费。

合约存储收费

涉及到存储收费的主要是在contracts/src/rent.rs中的collect_rent函数。

如果合约不存在或者为tombstone(可恢复的死亡状态)状态,则直接返回错误。

instantiate时,我们传入了endowment这个值,令合约账户中拥有资产。根据合约账户的free_balance大小,可以拥有一定大小的免费存储空间。

需要计费的合约代码大小的计算公式:occupied_storage = [ original_code_len + code.len() ] / refcount

original_code_len:原始合约代码大小;
code:注入gas_meter,stack_height_meter后编码成的wasm代码;
refcount:该合约代码的引用计数,即使用该合约代码的合约账户数。

而如果合约大小超过了这个免费空间,那么需要收费。需要支付的租用费用计算公式为:

  1. dues = fee_per_block * blocks_passed
  2. fee_per_block = RentFraction * uncovered_by_balance
  3. uncovered_by_balance = DepositPerStorageByte * (contract.storage_size + occupied_storage) + DepositPerStorageItem * contract.pair_count + DepositPerContract - free_balance

    RentFraction:每个区块租赁费用的乘法系数,决定了deposit对应的租赁费用有多高
    DepositPerStorageByte:每个存储字节需要的deposit
    DepositPerStorageItem:每个存储键值对需要的deposit
    DepositPerContract:每个合约存活就基本需要的deposit

根据合约可以用来支付rent的资产,决定合约的存活状态。
可以用来支付租赁费用的资产:rent_budget = min(contract.rent_allowance, free_balance - subsistence_threshold)

contract.rent_allowance:一个设置值(该合约可以用来支付租赁费用的最大值),刚部署时默认为balance的最大值,随着租赁费用的支付而逐渐减小。可以通过合约方法set_rent_allowance重新设置该值
实例存活下限:subsistence_threshold = Currency::minimum_balance() + TombstoneDeposit

如果合约的总资产(包括reserved)都小于实例存活下限,那么会永久删除合约。
如果足够支付,就直接扣除。如果不足以支付租赁费用,合约会变成tombstone状态。但是分为两种情况:
1.支付后账户都不能存在了,那么不进行扣除租赁费用;
2.支付后账户还能存在,还是要扣除租赁费用。
这里注意区分合约实例存活下限和合约账户存活下限。
合约变为tombstone状态时,保存一个键值对(account,(child_storage_root + code_hash))。对该合约的引用计数减1,如果合约的引用计数为0,会直接删除contracts模块中合约代码的存储。之后如果又有基于同样合约代码的部署,会被认为是新合约代码。

可见,随着时间的流逝,(如果不对合约账户进行充值)合约的资产会减少到无法维持Alive状态,从而变更为tombstone,甚至被永久删除。如果需要估算当前合约的资产能在链上实例存活多久,可以调用rent_projection来估算。

状态为tombstone的合约,在后面可以用状态为Alive的合约retore_to进行替换。

点击下方打赏按钮,获得支付宝二维码