本文主要介绍 substrate 合约模块的实现逻辑,srml/contracts
提供了部署和执行 WASM 智能合约的功能。作为一个模块化的区块链框架,不管是未来的波卡平行链还是基于 substrate 拥有独立共识的链,比如 ChainX, 只要引入其合约模块,就具备了合约功能,可以成为一个智能合约平台。ChainX 目前就计划引入合约功能,对区块链智能合约开发者提供支持, 欢迎有兴趣的同学持续关注。
substrate 的合约模块将会分两篇文章进行解读,本篇主要介绍基本概念,substrate 合约与以太坊合约的一些联系与区别,还会介绍一下上传合约代码 put_code
和实例化合约 instantiate
两个外部接口的实现。合约模块一共有 3 个接口,第二篇将会介绍第三个外部接口合约调用 call
的基本逻辑,并且会详细介绍下 substrate 关于合约存储收费的设计。
以下代码分析基于 substrate 的 9 月 21 日 4117bb9ff 版本。
基本概念
substrate 上的合约与以太坊合约有很多联系。首先普通账户和合约账户在外部表现上没有任何区别,都是一个哈希. 合约账户可以创建新的合约,也可以调用其他合约账户和普通账户。如果是合约账户调用普通账户,就是一个普通的转账。当合约账户被删除时,关联的代码和存储也会被删除。用户调用合约时,必须指定 Gas limit, 每次调用都需要花费 Gas 手续费, 合约内部调用的指令也会消耗 Gas.
当然也有一些区别。以太坊在合约调用中,如果出现任何问题,整个状态都会回滚。但是在 substrate 的合约中如果出现了合约嵌套调用,比如合约 A 调用了合约 B, 合约 B 调用了合约 C,B 在调用 C 的过程中发生错误,那么只有 B 这一层的状态回滚,A 调用产生的状态修改仍然保留。当以太坊出现类似情况时,整个合约调用链的状态都会回滚,也就是 A 调用的状态修改不会保留,而是会被丢弃。另外除了 Gas 费用,substrate 的合约还有一个 rent
费用, 也就是对于合约存储也进行了收费. 以太坊虽然已经有个相关的 EIP 针对存储收费的讨论 EIP 103, 但是目前还没有实施。
合约模块一共有三个与外部交互的接口:
put_code
: 上传代码, 将准备好的 WASM 合约代码存储到链上, 如果执行成功,会返回一个code_hash
, 然后可以通过这个code_hash
创建合约。先将代码存储到链上的好处是,对于合约内部逻辑相同而只有初始化参数不一样的合约,比如很多以太坊上的很多 ERC20 合约,链上只需要存储一份代码,而不需要每次新建一个合约的时候,都要存储一份重复的代码,这显然是冗余的。instantiate
: 实例化合约, 通过put_code
返回的code_hash
并传入初始化参数创建一个合约账户,实例化过程会调用合约内部的deploy
函数对合约进行初始化,初始化只有一次。最近 substrate 将合约模块的实例化方法从之前的create
重命名为了instantiate
, 见:PR: 3645。call
: 调用合约。在这里需要注意的是 substrate 有个存储收费的逻辑,如果调用的时候合约账户余额不足,合约就会被删除evict
, 很多人应该遇到过这种情况。
put_code
: 上传合约代码
- 调用
gas::buy_gas
根据gas_limit
预收取手续费。这一步是预先收取交易发起人的手续费。如果最后执行完成后,如果 Gas 没用完,会将剩余的 Gas 返还给用户。buy_gas
的代码在srml/contracts/src/gas.rs
。
收取手续费 = gas_price * gas_limit
- 将代码存储到链上,调用
wasm::code_cache::save
执行存储代码的逻辑,save
代码位于srml/contracts/src/wasm/code_cache.rs
。
在 save
中, 第一步是先收取 PutCode
操作的费用, 如果手续费不够直接返回。gas_meter
中就像是一个"Gas 小管家",这个管家管理的钱就是我们上一步预先收取的费用。在整个执行过程中,如果需要支付手续费,就从 gas_meter
中扣除,如果支付失败,直接返回。
关于手续费收取标准,也就是 gas_meter.charge(..)
接受两个参数,一个是 Token
trait 的关联类型 Token::Metadata
和实现了 Token 的 trait object, Token 有一个方法 calculate_amount
返应当收取的 Gas 费。srml/contracts/src/wasm/runtime.rs
中定义了一个枚举 RuntimeToken
, 它实现了 Token
trait, 针对不同的操作,收取不同的费用, 比如读内存,写内存,返回数据等等。在这里用到的 PutCodeToken(u32)
并不是 RuntimeToken
的成员,而是定义了一个元组结构体并实现了 Token
的 trait.
第二步是调用 srml/contracts/src/wasm/prepare.rs
中的 prepare_contract
函数对上传的原始代码进行校验和做一些预处理,如果全部校验通过,那么就会存储到链上。在这里会校验:
a. 入口函数是否存在: call
, deploy
b. 是否有定义内部存储
c. 内存使用是否超过阈值
d. 是否有浮点数
第三步将校验通过的代码组装成一个结构体 PrefabWasmModule
, 这个结构可以直接放到 WasmExecutable
里面, 然后写入存储。这里写入了两个存储,key 都是 code_hash
, 一个是原始代码 original_code
, 一个是 original_code
预处理后的 prefab_module
.
- 返回剩余的 gas.
instantiate
: 创建合约
通过 execute_wasm
构建 wasm 的基本执行过程。外部接口 instantiate
和 call
实际上都是要走 execute_wasm
,粗线条来讲,execute_wasm
第一步还是根据 gas_price * gas_limit
收取手续费, 然后构造一个顶层的执行环境 ExecutionContext
执行 wasm ,根据执行结果判断是否写入状态,返还剩余 Gas, 执行延迟动作,这里的延迟动作包括对于 runtime 模块的方法调用,抛出事件, 恢复合约等。ExecutionContext
是一个主要的结构体。
之所以会将 runtime 模块的方法调用放在最后执行,是因为目前的 runtime 模块中不支持状态回滚,这也是为什么目前所有 substrate 模块的写法都是先 verify, 各种 ensure!(...)
, 然后 write 写入存储, 因为一旦在 write 的过程中出现问题,已经 write 的部分状态已经改变,并且不可回滚。因此, 必须将所有的判断放在前面,保证所有判断通过,最后才执行写入动作。不过这个问题 substrate 已经在着手解决了,见: Substrate Issue: 2980, 估计再过一段时间应该就会支持 runtime 调用的状态回滚了。
execute_wasm
本质上是要执行 ExecutionContext 的方法, 代码在 srml/contracts/src/exec.rs
.
pub struct ExecutionContext<'a, T: Trait + 'a, V, L> {
pub parent: Option<&'a ExecutionContext<'a, T, V, L>>, // 是否有上层 context, 即是不是嵌套调用
pub self_account: T::AccountId, // 合约调用者
pub self_trie_id: Option, // 合约存储的 key
pub overlay: OverlayAccountDb<'a, T>, // 对于state的改动, 这里只是一个临时的存储,只有当合约执行完成后才会写到链上
pub depth: usize, // 合约嵌套深度
pub deferred: Vec>, // 延迟动作,因为现在 runtime 是一个先 verify 然后 write 并且不可回滚的原因,所有对于 runtime 的调用必须等合约完全成功后才能调用 runtime 里面的东西。
pub config: &'a Config,
pub vm: &'a V, // WasmVm::execute()
pub loader: &'a L, // WasmLoader::load_init(), WasmLoader::load_main()
pub timestamp: T::Moment, // 当前时间戳
pub block_number: T::BlockNumber, // 当前块高
}
ExecutionContext
有两个 public 方法对应两个外部接口的内部实现。
-
call
: 合约调用逻辑 -
instantiate
: 合约创建逻辑。
在 ExecutionContext::instantiate
中,首先判断调用深度,然后收取实例化的费用,接着计算合约地址, 地址计算公式:
合约地址 = blake2_256(blake2_256(code) + blake2_256(data) + origin)
- code: 合约代码,
blake2_256(code)
就是 put_code 返回的 code_hash. - data: 合约初始化参数
- origin: 合约创建者账户
然后通过 nested.overlay.create_contract(..)
创建合约, overlay 的类型是 OverlayAccountDb
, 所以实际上调用的是 OverlayAccountDb::create_contract
, 代码在 srml/contracts/src/account_db.rs
.
pub struct OverlayAccountDb<'a, T: Trait + 'a> {
local: RefCell>,
underlying: &'a dyn AccountDb,
}
创建合约这里主要是向合约默认值注入了两项内容,一个是 code_hash
, 另一个是 rent_allowance
, 这个 rent_allowance
会在之后收取存储费用的时候用到, 默认是最大值。
然后刚刚创建好的合约账户进行 transfer 的动作, 紧接着 nested.loader.load_init(..)
加载合约的构造函数 delopy
进行初始化。loader
的类型是 WasmLoader
, 也就是调用 WasmLoader::load_init
, 代码在 srml/contracts/src/wasm/mod.rs
。
load_init
和 load_main
实际上都是调用的 load_code
, 它会比较 schedule 的版本,还记得我们之前在 put_code
的最后是写入了两个存储,一个是原始代码,一个是原始代码预处理后的 prefab_module
. 如果当前版本大于已经预处理好的版本, 那么需要重新预处理,否则直接返回已经存储的 prefab_module
。load_init
最终返回 WasmExecutable
结构体 executable
。
然后将返回的 executable
放到 WasmVm
执行 execute
。WasmVm
实现了 Vm
trait, 这个 trait 定义了 execute
方法,代码在 srml/contracts/src/wasm/mod.rs
。execute
首先会在沙盒sandbox
中开辟一段新的存储用于执行 wasm 代码. execute
在最后是构建一个 sandbox::Instance
, 调用了 Instance
的 invoke
方法, 这部分代码在 core/sr-sandbox/src/lib.rs
,
core/sr-sandbox/src/lib.rs
中的 Instance::invoke
实际调用的是 srml/sr-sandbox/src/with_std.rs
或者 srml/sr-sandbox/src/without_std.rs
的 Instance::invoke
。std 下调用的是 wasmi 库, wasmi::ModuleInstance
的 invoke_export
.
执行完 deploy
初始化以后,检查合约账户余额是否足够,如果低于账户存在的最小额,返回错误。
如果一切顺利,OverlayAccountDb
进行 commit, 注意这里还没有正式写入存储。回到最外层的 execute_wasm
, 如果这里执行正确,DirectAccountDb
进行 commit,这里才是真正写到存储里面。然后又是正常的返回剩余 Gas, 和执行延后的 runtime 调用等等。
简单回顾一下,GasMeter
负责在合约执行过程中扣手续费,所有操作都是先收费. ExecutionContext
是外部接口 instantiate
和 call
的具体执行环境。OverlayAccountDb
是合约执行过程的临时存储,用来支持合约回滚。DirectAccountDb
在合约最终执行完毕后,负责真正写入存储。以上就是上传合约代码和实例化合约的大概流程,下一篇会主要介绍合约调用,合约恢复以及合约存储收费的主要内容。