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

本文主要介绍 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 在合约最终执行完毕后,负责真正写入存储。以上就是上传合约代码和实例化合约的大概流程,下一篇会主要介绍合约调用,合约恢复以及合约存储收费的主要内容。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,295评论 6 512
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,928评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,682评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,209评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,237评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,965评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,586评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,487评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,016评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,136评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,271评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,948评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,619评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,139评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,252评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,598评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,267评论 2 358