HOME
Damoclis-VM 诞生记(一)—— 智能合约的设计与思考

作者:杨奕辉 创新平台实验室

项目参与者: 杨奕辉 强宇周 丁天宇

Damoclis-VM是创新平台实验室的旗舰项目Damoclis的子项目,是对智能合约和虚拟机的设计与实现。整个过程充满未知与挑战,我们从零开始调研,到合约语言和虚拟机方案的选型,到env api的设计和虚拟机实现,最后形成一个MVP。收获良多,也对区块链及智能合约的理解更为深刻,因此有必要为它写一系列文章,记录一下它的诞生。

《Damoclis-VM 诞生记》系列包含三篇文章:

  • 《Damoclis-VM 诞生记(一)—— 关于智能合约的设计与思考》
  • 《Damoclis-VM 诞生记(二)—— 字节码的选型与WebAssembly》
  • 《Damoclis-VM 诞生记(三)—— 虚拟机的设计与思考》

智能合约

通俗地说,智能合约是可运行在区块链上的代码逻辑,而与传统的服务器脚本不同的是,它赋予了应用两个重要特性:

  • 利用链上数据判定合约条件,满足时自动执行,无任何机构能干预这一过程。
  • 执行过程满足all or nothing,即原子性,一个transaction中的所有操作执行要么全部成功要么全部失败。

区块链本质是一个自动状态机,智能合约为开发者提供了可编程的状态变迁接口,使得我们能够以区块链作为数据后端开发Dapp。如果将区块链归为Data Layer和Runtime Environment,则智能合约应无疑是App Layer。

从技术角度看,智能合约的工作原理并不复杂——

  1. 智能合约以字节码形式部署在区块链上。
  2. 开发者以transaction的形式包裹想要调用的智能合约方法与参数,发送给虚拟机。
  3. 虚拟机获取对应的合约字节码,利用dispatcher完成合约方法的调用。
  4. 异步response,即只有当交易被打包进区块并上链确认后,本次调用才真正有response。

理论上说,符合以上原理的脚本,都可视为一种智能合约的实现。然而仅仅这样还不够,我们还需要为智能合约提供必要的特性:

  • 确定性。
  • 停机问题。
  • 资源模型。
  • 读写区块链数据。
  • 跨合约调用。
  • 资源隔离。

当把上面这些问题考虑进去之后,问题就变得复杂很多。我们一一来看——

确定性

智能合约作为区块链状态变迁的执行脚本,必须满足确定性,以让所有节点在不同的计算机设备与不同时刻运行,相同的输入总能输出相同的结果。使合约产生非确定性的因素很多,总结起来有以下几种——

浮点数,不同操作系统、不同硬件设备的浮点数精度可能不一样,导致涉及浮点数运算时容易得出不同的结果。因此,主流的合约方案如solidity是禁止使用浮点数的,EOS将资产标量中涉及的浮点数(如1.0000 EOS),编码进64位整型的地址空间,来模拟浮点数运算,本质上还是使用的整数类型。

调用非确定性系统函数,即比如调用了生成随机数、获取系统时间、获取当前区块头等非确定性函数,这使得结果是非确定性的。目前的合约方案会将这些非确定性函数与区块链的状态挂钩,如获取系统时间改为获取当前区块打包的时间随机数则由区块链数据作为种子来生成,在相同高度的区块下能够得到相同的随机数等。

使用非确定性数据来源,典型例子是合约执行中使用预言机获取外部数据,也会使得相同的输入产生非确定性结果。目前还没有成熟方案能够实现向非确定性数据源(如互联网)获取数据。

动态调用,指一个合约调用另一个合约方法,且调用目标只有在合约运行时才能被确定下来。动态调用在目前已有的合约方案中几乎不存在。

停机问题

停机问题是逻辑数学中可计算理论的一个问题。通俗地说,停机问题就是判断任意一个程序是否能在有限时间之内结束运行的问题。而1936年,停机问题已被艾伦·图灵证明在图灵机上是不可判定问题,即不存在解决停机问题的通用算法。

为什么在设计智能合约中需要考虑停机问题?我们须要防止开发者在智能合约中编写无限循环,使得虚拟机在运行时一直卡在某次合约调用,导致整个区块链工作异常。因此我们需要设计一个机制,能够让在符合一定条件下使得智能合约停止执行。

既然不存在通用算法,那只能根据不同的区块链协议,来设计不同的解决方案。下面介绍两种主流方案——

计价器

以太坊的EVM开创性地使用gas计价来解决停机问题。每一个transaction包含了gasLimitgasPrice字段,执行合约字节码须要消耗gas(不同字节码操作对应不同gas),当累积消耗gas超过gasLimit时停止合约执行。

计时器

EOS的合约利用控制执行时间来解决停机问题,即用户须要预先抵押EOS换取CPU资源(以时间为单位,如300ms),在合约执行前在关键位置注入checktime(),执行时不断检查时间,若已超出时间上限,则停止执行。

这两种方案无优劣之分,均可达成目的,但各有取舍。计时器是主观计算,不同机器的执行时间可能不同。EOS主网目前就出现了各BP处理相同交易所消耗的CPU花费不同,解决方案也非常折中——验证节点不对执行时间做校验,保留出块节点的执行时间结果。CPU时间是可恢复时间,并不真正收取手续费;而计价器的方案只须要定义好字节码执行对应消耗的gas,即可在任何设备上获得相同的gas消耗,但每次执行需要扣除相应手续费。由于计价器实现简单,不需要其他合约辅助参与,因此绝大部分区块链项目都采用gas机制来解决公链资源收费和合约停机问题。

资源模型

在公有链环境,由于区块链硬件资源是有限的,如何合理地给合约执行和数据存储分配资源,以优化计算资源配置,防止被恶意滥用,是一个非常关键的问题。这里我们依然用以太坊和EOS来进行分析。

基于Gas的资源模型

合约执行消耗的gas消耗乘以区块字段gasPrice,得到最终消耗的代币数量。对于数据存储,除了读写时的字节码收费外,没有额外的收费机制。

基于质押兑换的资源模型

EOS的资源模型:

  • CPU: 用于交易执行时的时间用量。通过抵押获得,可恢复。
  • NET: 交易带宽用量。通过抵押获得,可恢复。
  • RAM: 数据存储空间。需要向系统购买。

CPU/NET均为可恢复资源,即使用后24小时后自动恢复;RAM是存储空间,属于占用型资源,需要提前购买。当数据存入时消耗,数据删除时释放。

对于合约执行收费来说,gas机制针对用量收费,粒度非常细,且确定性统计。对数据存储则是一次性收费,数据删除时并不归还存储空间费用。gas机制很类似于目前云计算服务的用量收费模式;而资源抵押与兑换的机制则更像自己购买了硬件资源,若不需要了可再将资源变卖以回收成本。但在公链环境中须要形成合理的资源市场定价,具备未知的风险。实现上,需要以智能合约形式实现资源的买卖与抵押,同时对于RAM还需要引入合理的发行机制(如EOS中使用Bancor算法来对RAM定价并由系统发行,在主网上线早期出现了割韭菜的现象)。

读写区块链数据

读写区块链数据是智能合约的核心功能,也是其被称为“智能”所在。

  • 读:合约调用时可读取区块链的数据,判定合约条件并自动执行,这一过程是原子的,且不受任何人干预。
  • 写:将区块链状态视为数据库,写入合约状态数据,实现更复杂的业务逻辑。

对于写入数据来说,EVM1采用一次性收取gas的模式,存在一定的不合理性。V神在2018年提出了状态租金的概念,即须要为存储空间的占用存储租金,Nervos CKB也沿用了这一设计并实现在Cell模型中。EOS采取不是空间(RAM)出租,而是买卖的形式,即用户须要先买入存储空间,才能存储数据;数据删除后可以将空间卖回给系统。空间的价格是由Bancor算法指定,模拟了市场供需关系,但由于是人机交易,做空做多都不需要市场深度,价格容易产生巨大波动。如EOS主网上线时RAM价格被炒至10RMB/KB,远高于云服务器的存储空间费用。

跨合约调用

跨合约调用大大增强了智能合约的互操作性,简化了开发成本。同时,能够以合理的形式开放合约状态数据,也避免了合约成为数据孤岛,大大扩展了业务的想象空间。

跨合约调用必须是确定性的静态调用——在运行前即知晓被调用合约的地址,且调用结果是确定性的。

对跨合约调用的设计,我们须要注意两个方面——

上下文的切换

上下文切换常发生在跨合约调用中:合约A调用合约B时,上下文应是合约A还是变更为合约B。典型例子,在Solidity中,msg.senderstorage是上下文相关的,语言提供了call,callCode,delegateCall来适应不同的上下文切换的需求(三者具体区别可阅读文章)。在EOS中,跨合约调用的上下文永远是被调用合约,由于调用action须要显式指定调用者账户(类似solidity中的msg.sender),且storage与上下文分离,因此没有以太坊那样的问题,使用起来更加简单。

权限控制

权限控制是跨合约调用中不可避免的问题,它关系到用户的数据安全。我们来看下面这个例子:

Bob调用合约A的hi方法,hi中包含一个跨合约调用,形如B.hello(),调用了合约B的hello方法。

那么问题来了:Bob只是希望调用合约A的方法,而合约A是否有权利以Bob的账户调用合约B?

EVM实际并没有考虑权限的问题。开发者在编写合约时可自行选择call,callCode,delegateCall中任意一种方式调用其他合约。这么做似乎并没有太大的影响,因为以太坊合约部署后任何人不能修改,也无法升级,只要用户确认了代码是符合要求的,那调用的后果就应由用户自行负责。然而,合约开发者们还是担心资产安全问题,主流的资产标准(如ERC20)都提供了授权的接口,即只有被授权的合约才能进行转移用户的相应资产,以此实现权限的控制。

而在EOS中,由于合约代码可以升级,情况则大不一样:假如合约A的运营方在某次合约升级中(或被黑客攻击)悄悄把hi方法中对合约B的合约调用改为transfer,把Bob的xxx Token转账给自己。Bob可能并不能及时知晓代码更新,则很有可能在后续调用中触发转账操作,丢失资产。为了解决这一情况,EOS提供了两种方法:

  • Bob创建新的权限,并授予合约的eosio.code权限,并指定合约A的hi和合约B的hello,如此Bob账户只能用于调用A.hiB.hello
  • 新增了require_receipient的通知方法,它使用合约A账户而非Bob账户调用合约B,并修改上下文变量以指明来源方。

在Damoclis-VM中,由于依然沿用账户地址而非EOS的账户名系统,我们设计了如下机制来解决跨合约调用中权限变更的问题:

  • 跨合约调用仅有一种方法,即call_action
  • 跨合约调用的发起方地址与当前交易的发送方地址一致。这保证了前后权限的一致。
  • 提供专门的字段caller记录调用者。普通交易的调用者为发送方;跨合约调用的调用者为当前合约。

资源隔离

图灵完备的智能合约意味着可以编写并执行任意的逻辑,包括病毒。如果智能合约能直接在区块链节点的宿主系统上运行,病毒就能进行自我复制,破坏宿主系统的自身数据。因此智能合约必须放在一个隔离的沙盒环境中运行。这里将在第三篇文章中着重讲解。

智能合约架构

上图为智能合约的一个基本架构图,自上而下分别是合约层编译层注入层执行层

合约层提供了智能合约开发的语言与代码库,以及与区块链交互的必要API;

编译层负责将合约代码编译为虚拟机能执行的字节码;

注入层一般在合约执行前给合约字节码注入一些组件,包括Env API的具体实现,Gas的度量函数,以及构建合约执行时的上下文环境;

执行层检查合约的执行权限,创建沙箱环境并分配资源,使用解释器运行合约字节码。执行过程中提供状态数据库与区块链账本作为数据后端。

智能合约的设计与选型

优秀的智能合约设计应该是场景驱动的。严格来说,智能合约不算是基础设施,而更像开发框架。既然是开发框架,其设计目标应该是纯粹的——以最低成本、最简单的形式全力解放目标领域的生产力。

例如,比特币区块链专注于货币交易,其脚本语言也非常简单,且是非图灵完备的;以太坊与EOS目标是通用的信任基础设施,故设计了图灵完备、开发体验良好的智能合约方案;Zilliqa目标是提供公开平台给需要高可扩展性计算资源的应用,如数据挖掘、机器学习、金融模型等,场景比较垂直,其合约语言Scilla也设计成非图灵完备的,能充分满足领域需求,易于形式化验证,且语法更简洁;Libra目标是提供安全稳定的基础设施运行其稳定币,其智能合约Move将资产提升为first-class,不得随意复制、创建,提升了编写难度,但从语言层面保证了资产的一致性。

Damoclis项目的定位是通用的信任计算和信任存储的基础设施,对标以太坊与EOS。因此,Damoclis-VM的智能合约应是图灵完备的,同时要兼顾良好的开发体验、与区块链交互功能的完备性以及合约安全性。对智能合约的设计与选型,我们主要在以下四方面开展工作:

  • 合约语言
  • 合约库
  • Environment API
  • 安全性

合约语言

合约语言的选型是智能合约选型中的核心环节。由于Damoclis-VM的虚拟机使用的是WebAssembly字节码技术,因此在合约语言选型中,我们遵循了以下四个原则:

  • Damoclis团队较为熟悉。
  • 图灵完备,语法简洁,且已有大量开发者使用。
  • 市面上已有该语言的成熟的wasm编译器。
  • wasm编译器的二次开发成本较低。

这么筛选下来,我们的选择范围收敛至:C/C++,Rust,Go,TypeScript。

首先看由TIOBE提供的2019年6月份语言流行度排名:C/C++ > JavaScript > Go > Rust > TypeScript;

其次是社区Wasm编译器的成熟度:C/C++ ≈ Rust > TypeScript > Go;

然后是Wasm编译器二次开发成本:Typscript << Go < Rust ≈ C/C++;

最后是语言学习成本:TypeScript < Go < Rust < C/C++。

具体来看,先排除C/C++与Go,C/C++语法过于复杂,学习成本较高,其对于智能合约的场景是“杀鸡用牛刀”;Go的wasm编译器并不成熟,编译出来的wasm文件体积非常大(Hello World代码对应的wasm文件竟有1M的大小),对于链上稀缺的存储资源造成浪费。其次是排除Rust,原因是团队中无人熟悉Rust,且学习成本较高,有太大未知风险。最后是TypeScript,语法简单,且团队中有人使用TypeScript开发过大型应用,较为熟悉。尽管TypeScript的流行度排名较低,但它是Javascript的超集,后者长年排在流行度TOP10之内,且JS开发者通常都能快速入门TypeScript。另外,社区提供了AssemblyScript作为TypeScript => Wasm的编译器,本身是TypeScript编写,具有很强的二次开发潜力。综合考虑,我们选择使用TypeScript作为Damoclis-VM的合约语言

合约库

合约库是对合约开发中常用到的数据结构与API进行良好的封装,以降低开发成本。

Environment API

Env API提供了合约与区块链系统进行数据交互的底层接口。一套最小可用的Env API应至少包含以下功能:

  • 读写状态数据库
  • 读取区块链账本数据
  • 读取上下文参数,如合约地址、合约发送方地址、合约方法与参数等
  • 跨合约调用

另外还可添加如哈希函数、加解密、打印日志等API。

安全性

合约安全分为编译期安全执行期安全。Damoclis-VM选择基于WebAssembly字节码的合约方案,wasm编译器是我们基于社区版AssemblyScript进行二次开发的,可基本确定没有恶意后门,并且二次开发中并不对原核心编译逻辑做更改,只是增加新的代码生成和ABI生成,故我们认为编译期安全得到保障。执行期安全主要由虚拟机保障,这一块会在系列第三篇讲解。

TypeScript合约

Damoclis-VM利用TypeScript作为合约语言,开发者能够利用熟悉的开发工具(VSCode、Webstorm)快速开发出满足条件的合约产品。下面展示一个使用TS实现的合约例子:

import { Contract, Prints, SafeMath, Database, Assert } from "dmc-lib";


// 声明数据表person,定义每列数据的字段。底层为k-v型存储。
// 简单来说:在合约的数据表`person`中,存储若干`Person`数据项。k为类种定义的key,v为整个类的序列化字符串。
//
// 使用该装饰器便于编译器识别并生成abi。只有在abi中的数据表才能通过外部api查询。
@database("person")
class Person implements Serializable {
	@key
	name: string
	age: u32;
}

class HelloWorld extends Contract {
	// 定义合约方法`hi`
	@action
	hi(msg: string): string {
		// 打印日志
		Prints(msg);
		return msg;
	}

	// 定义合约方法`add`
	@action
	add(a: u64, b: u64): u64 {
		return SafeMath.add(a, b);
	}
	
	// 定义合约方法`store`
	@action
	store(): void {
		// 创建数据项实例
		const person = new Person();
		person.name = "bob";
		person.age = 16;
		
		// 创建一个数据库实例
		// 第一个参数为当前合约地址,第二个参数为数据表名称,与上面@database装饰器所定义的表名须一致
		const db = new Database<Person>(this.self, "person");
		
		// 写操作只对合约本身的数据表生效
		db.store(person);
		
		const person2 = db.get("bob");
		// 断言函数,如果第一个参数结果是false,则会终止执行并打印第二个参数的信息。
		Assert(person2.name == person.name, "person name mismatch");
	}
}

推荐阅读

[1] Scilla: a Smart Contract Intermediate-Level LAnguage

[2] Move 语言:我眼中的 Libra 最大亮点

[3] Difference between CALL, CALLCODE and DELEGATECALL

[4] First-class Asset