substrate 合约模块简要剖析(一)

  • 2019 年 10 月 8 日
  • 筆記

本文主要介绍 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, 但是目前还没有实施。

合约模块一共有三个与外部交互的接口:

dispatchable

  • put_code: 上传代码, 将准备好的 WASM 合约代码存储到链上, 如果执行成功,会返回一个 code_hash, 然后可以通过这个 code_hash 创建合约。先将代码存储到链上的好处是,对于合约内部逻辑相同而只有初始化参数不一样的合约,比如很多以太坊上的很多 ERC20 合约,链上只需要存储一份代码,而不需要每次新建一个合约的时候,都要存储一份重复的代码,这显然是冗余的。
  • instantiate: 实例化合约, 通过 put_code 返回的 code_hash 并传入初始化参数创建一个合约账户,实例化过程会调用合约内部的 deploy 函数对合约进行初始化,初始化只有一次。最近 substrate 将合约模块的实例化方法从之前的 create 重命名为了 instantiate, 见:PR: 3645
  • call: 调用合约。在这里需要注意的是 substrate 有个存储收费的逻辑,如果调用的时候合约账户余额不足,合约就会被删除 evict, 很多人应该遇到过这种情况。

put_code: 上传合约代码

  1. 调用 gas::buy_gas 根据 gas_limit 预收取手续费。这一步是预先收取交易发起人的手续费。如果最后执行完成后,如果 Gas 没用完,会将剩余的 Gas 返还给用户。buy_gas 的代码在 srml/contracts/src/gas.rs
收取手续费 = gas_price * gas_limit
  1. 将代码存储到链上,调用 wasm::code_cache::save 执行存储代码的逻辑, save 代码位于 srml/contracts/src/wasm/code_cache.rs

save_code

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.

  1. 返回剩余的 gas.

instantiate: 创建合约

通过 execute_wasm 构建 wasm 的基本执行过程。外部接口 instantiatecall 实际上都是要走 execute_wasm,粗线条来讲,execute_wasm 第一步还是根据 gas_price * gas_limit 收取手续费, 然后构造一个顶层的执行环境 ExecutionContext 执行 wasm ,根据执行结果判断是否写入状态,返还剩余 Gas, 执行延迟动作,这里的延迟动作包括对于 runtime 模块的方法调用,抛出事件, 恢复合约等。ExecutionContext 是一个主要的结构体。

execute_wasm

之所以会将 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<TrieId>, // 合约存储的 key      pub overlay: OverlayAccountDb<'a, T>, // 对于state的改动, 这里只是一个临时的存储,只有当合约执行完成后才会写到链上      pub depth: usize, // 合约嵌套深度      pub deferred: Vec<DeferredAction<T>>, // 延迟动作,因为现在 runtime 是一个先 verify 然后 write 并且不可回滚的原因,所有对于 runtime 的调用必须等合约完全成功后才能调用 runtime 里面的东西。      pub config: &'a Config<T>,      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 中,首先判断调用深度,然后收取实例化的费用,接着计算合约地址, 地址计算公式:

contract_address

合约地址 = 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<ChangeSet<T>>,      underlying: &'a dyn AccountDb<T>,  }

create_account

创建合约这里主要是向合约默认值注入了两项内容,一个是 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_initload_main 实际上都是调用的 load_code, 它会比较 schedule 的版本,还记得我们之前在 put_code 的最后是写入了两个存储,一个是原始代码,一个是原始代码预处理后的 prefab_module. 如果当前版本大于已经预处理好的版本, 那么需要重新预处理,否则直接返回已经存储的 prefab_moduleload_init 最终返回 WasmExecutable 结构体 executable

然后将返回的 executable 放到 WasmVm 执行 executeWasmVm 实现了 Vm trait, 这个 trait 定义了 execute 方法,代码在 srml/contracts/src/wasm/mod.rsexecute 首先会在沙盒sandbox中开辟一段新的存储用于执行 wasm 代码. execute 在最后是构建一个 sandbox::Instance, 调用了 Instanceinvoke 方法, 这部分代码在 core/sr-sandbox/src/lib.rs,

sandbox_imp

core/sr-sandbox/src/lib.rs 中的 Instance::invoke 实际调用的是 srml/sr-sandbox/src/with_std.rs 或者 srml/sr-sandbox/src/without_std.rsInstance::invoke。std 下调用的是 wasmi 库, wasmi::ModuleInstanceinvoke_export.

执行完 deploy 初始化以后,检查合约账户余额是否足够,如果低于账户存在的最小额,返回错误。

如果一切顺利,OverlayAccountDb 进行 commit, 注意这里还没有正式写入存储。回到最外层的 execute_wasm, 如果这里执行正确,DirectAccountDb 进行 commit,这里才是真正写到存储里面。然后又是正常的返回剩余 Gas, 和执行延后的 runtime 调用等等。

简单回顾一下,GasMeter 负责在合约执行过程中扣手续费,所有操作都是先收费. ExecutionContext 是外部接口 instantiatecall 的具体执行环境。OverlayAccountDb 是合约执行过程的临时存储,用来支持合约回滚。DirectAccountDb 在合约最终执行完毕后,负责真正写入存储。以上就是上传合约代码和实例化合约的大概流程,下一篇会主要介绍合约调用,合约恢复以及合约存储收费的主要内容。