分析基于 substrate 的 2021年3月11日 b24c43af
版本。本文将介绍合约模块的合约调用 的基本逻辑,并且会介绍关于合约存储收费的设计。
调用合约
合约调用的大致流程,下图中只显示了ExecutionContext::call
的内部逻辑,ExecutionContext::instantiate
其实也是类似的。主要在于理解链的runtime层与合约WASM代码是怎么交互的。
手续费
在Substrate的设计中,调用交易需要根据Weight收取交易手续费。contracts模块定义了几个主要执行接口:call
、instantiate_with_code
和instantiate
。其中,instantiate_with_code
接口包含了早期版本中的put_code
和instantiate
,是为了对合约代码存储进行收费。
在调用以上三个接口时,除了收取由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模块)暴露两个执行入口:Constructor
、Call
。在contracts模块中,Constructor
被封装在ExecutionContext::instantiate
中,Call
被封装在ExecutionContext::call
中。
contracts模块中的三个接口调用ExecutionContext
的两个封装后的执行入口,调用关系如下图所示。
ExecutionContext
是合约执行上下文,合约调用另一个合约的情况下,直接调用封装在ExecutionContext
中的接口,不需要从最外层的三个接口调用,因此不用再收取contracts模块中定义的固定Weight的手续费。
call
在ExecutionContext::call
中,需要根据code_hash
加载合约代码,生成一个wasm module作为executable
。然后根据dest
获取对应的合约实例,并对该实例进行存储费用计算。该部分涉及费用计算,因此在合约存储收费中介绍。
关于load
加载合约代码:它会比较 schedule 的版本(instantiate_with_code
在上传合约代码时,写入一个是原始代码处理后的 PrefabWasmModule
,包含了schedule_version
、original_code
、code_hash
和refcount
等)。如果当前版本大于PrefabWasmModule
中的版本, 那么需要重新预处理,否则直接返回已经存储的 PrefabWasmModule
。
schedule内主要定义合约命令的weight和host_fn的weight,影响了手续费的计算。
嵌套执行上下文
在执行具体业务逻辑之前,需要把执行上下文包装在新的ExecutionContext
中,并且在最后能截获当前合约执行的结果。
ExecutionContext执行
在NestedExecutionContext
环境中,执行以下的业务逻辑。
如果调用时传入的参数value
不为0的话,会调用transfer
将value
传给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
:该合约代码的引用计数,即使用该合约代码的合约账户数。
而如果合约大小超过了这个免费空间,那么需要收费。需要支付的租用费用计算公式为:
dues = fee_per_block * blocks_passed
fee_per_block = RentFraction * uncovered_by_balance
uncovered_by_balance = DepositPerStorageByte * (contract.storage_size + occupied_storage) + DepositPerStorageItem * contract.pair_count + DepositPerContract - free_balance
RentFraction
:每个区块租赁费用的乘法系数,决定了deposit对应的租赁费用有多高DepositPerStorageByte
:每个存储字节需要的depositDepositPerStorageItem
:每个存储键值对需要的depositDepositPerContract
:每个合约存活就基本需要的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
进行替换。