Facebook最近发表了联盟链项目Libra,其中的最大亮点是Move语言。
下面我们从技术视角解读一下“Move: A Language With Programmable Resources”这篇白皮书,供大家参考。
为了便于理解,我们拿比特币、以太坊和Libra来做一个对比。
其实,单从白皮书的标题,就可以大概看出三个项目在设计目标上的差异。
比特币的目标是——可编程货币(Programmable Money),所以白皮书标题是“Bitcoin: A peer-to-peer electronic cash system”。
以太坊的目标是——可编程的去中心化应用(Programmable dApps),在货币的基础上,扩展到更通用的领域。所以白皮书标题是:“Ethereum: a next generation smart contract and decentralized application platform”,黄皮书标题是:“Ethereum: A secure decentralized generalized transaction ledger”。
而Libra的设计目标恰好介于二者中间——可编程资源(Programmable Resources),或者叫可编程资产。
Facebook的技术路线比较务实,没有尝试更颠覆性的创新,而是把目光聚焦在“货币”和“通用应用”之间的“资产”,围绕解决实际问题,便于工程实现而展开。从这点来看,Libra既不是区块链3.0也不是4.0,而是区块链1.5。但这并不代表Libra的目标没有挑战,事实上,实现一个可以保证资产安全性,又能够提供足够灵活性的系统,比臆想出一个解决“不可能三角”的永动机还要困难。
那么,“可编程货币”、“可编程应用”、“可编程资源”,这三者到底有什么不同呢?
既然都是“可编程XX”句式,他们的主要区别就在于两点:1)对什么编程,2)如何编程。
对什么编程,是指系统所描述或者抽象的,到底是现实世界中的什么东西。
比特币系统抽象的是“货币”,或者说是“账本”的概念。货币可以用一个数字来描述,也就是某一个账户的“余额”。用户可以通过“交易”,把一部分钱转给别人。当比特币网络接收到一笔交易的时候,每个节点都会检查交易是否合法,比如你花的是不是自己的钱,有没有足够的余额(比特币不允许透支)。当这些检查都成功后,节点会做一个简单的加减计算:在你的账户中扣减转账的数额,并在对方账户中加上同样的数量。因此,比特币唯一的功能就是记账,保证在账户彼此转账的过程中,货币的总量不会莫名其妙的增加或减少(不考虑挖矿奖励和黑洞地址等特例)。
以太坊系统抽象的是“应用”,应用的种类包罗万象,比如游戏、借贷系统、电商系统、交易所等,这些都是应用。理论上讲,任何传统的计算机程序都可以移植到以太坊上。因此,以太坊中记录的是各种应用的内部数据(即“合约状态”),比如一个电商系统的库存、订单、结算信息等。这些信息无法用一个简单的数字来描述,必须允许用户定义非常复杂的数据结构,并且允许用户通过代码(智能合约),来对这些数据进行任意所需的操作。当然,这些应用也包含了“货币账本”。事实上,目前在以太坊上应用最广泛的正是此类应用(称为“ERC20智能合约”)。由于以太坊把这类应用看作是平台所能支持的多种应用中的一种,与其他类型的应用相比,并没有什么特别之处,所以也就没有针对此类应用提供更多的安全保护,只提供了类似ERC20这样的接口规范。一个在以太坊上新发行的“货币”,其转账逻辑的正确性完全由开发者负责。
在以太坊的存储结构中,ERC20代币的账本是“二级对象”,和ETH原生代币余额存储在不同的地方。例如上图所示,0x0,0x1和0x2是三个以太坊地址,其中,0x0和0x2是普通账户地址(External accounts),而0x1是一个合约地址(Contract accounts)。我们可以看到,每个账户都存储了一个ETH的余额,这个数据是顶级对象(First-Class Object)。在合约地址0x1中,还存储了一个智能合约代码MyCoin,它是一个ERC20代币应用。而MyCoin这个代币的整个账本,都存储在0x1的空间中,怎么修改都由0x1中的合约代码说了算。
无论是有意还是无意,ERC20代币非常容易出现安全漏洞。也就是说,在以太坊系统中,原生代币ETH和用户发行的代币并不享有同样的安全级别。
那么,能否不那么走极端,试图去抽象一些比简单数字更复杂的资产类型,而又不追求包罗万象的“通用性”呢?这正是Libra的出发点。Libra可以定义类似一篮子货币、金融衍生品等比货币更复杂的资产类型,以及如何对他们进行操作,这种资产被称为“资源”。Move通过限制对资源的操作来防止不恰当的修改,从而提高资产的安全性。无论资源的操作逻辑如何,都必须满足两个约束条件:
稀缺性。即资产总量必须受控,不允许用户随意复制资源。通俗的说,就是允许银行印钞,但不允许用户用复印机来“制造”新钱;
权限控制。简单的说就是资源的操作必须满足某种预先定义的规则。例如,张三只能花自己的钱,而不允许花李四的钱。
上图是Move的世界状态,与以太坊不同,它把所有资产都当作是“顶级资源”(First-Class Resources),无论是Libra的原生代币,还是用户自己发行的资产。任何一个“币种”的余额,都存储在用户地址对应的空间中,对其进行操作受到严格的限制。这种被称为资源(resource)的对象,在交易中只能被移动,而且只能移动一次,既不能被复制,也不能被消毁。甚至严格到在代码中赋值给一个局部变量,而后面没有使用它也不允许。
这种资产的存储方式并非Libra独创,在此前的一些公链中已有应用,例如在Vite公链中,用户发行的币种余额也是顶级对象。不过Move可以支持更为复杂的资产类型,并对其提供额外的保护,这是Libra的主要贡献。
我们再来看看三个项目如何通过编程来实现丰富的扩展性。
在比特币中,定义了一种“比特币脚本”,用来描述花一笔钱的规则。比特币是基于UTXO模型的,只有满足了预先定义的脚本规则,才能花费一笔UTXO。通过比特币脚本,可以实现“多重签名”之类的复杂逻辑。比特币脚本是一种非常简单的基于栈的字节码,不支持循环之类的复杂结构,也不是图灵完备的。虽然利用它可以在比特币网络上发行新的货币(Colored Coins),但它的描述能力非常有限,对开发者也不友好,无法应用到更复杂的场景中。
在以太坊中,定义了一种Solidity的编程语言,可以用来开发“智能合约”。智能合约代码可以编译成一种基于栈的字节码——EVM Code,在以太坊虚拟机EVM中执行。Solidity是一种高级语言,参考了C++、Python和Javascript的语法,是一种静态类型、图灵完备的语言,支持继承,允许用户自定义复杂的类型。Solidity更像是一种通用的编程语言,理论上可以用来开发任何类型的程序,它没有针对货币或者资产类型的数据,在语法和语义上做任何限制和保护。比如用它来开发一个新的代币合约,代币的余额通常声明为uint类型,如果编码时对余额增减逻辑的处理不够小心,就会使余额变量发生溢出,造成超额铸币、随意增发、下溢增持等严重错误。
再来看Libra,它定义了一种新的编程语言Move,这种语言主要面向资产类数据,基于Libra所设定的“顶级资源”结构,主要设计目标是灵活性、安全性和可验证性。目前,Move高级语言的语法设计还没有完成,白皮书只给出了Move的中间语言(Move IR)和Move字节码定义。因此我们无法评估最终Move语言对开发者是否友好,但从Move IR的设计中,可以感受到它在安全性和可验证性方面的特点。
下面我们来简单介绍一下Move的语法。Move的基本封装单元是“模块”(Module),模块有点类似于以太坊中的“智能合约”,或者面向对象语言中的“类”。模块中可以定义“资源”(Resource)和“过程”(Procedure),类似于类中的“成员”(Member)和“方法”(Method)。所有部署在Libra上的模块都是全局的,通过类似于Java中的包名+类名的方式来引用,例如0x001.MyModule,0x001是一个Libra地址,MyModule是一个模块名。模块中的过程有public和private两种可见性,公有过程可以被其他模块调用,私有过程只能被同模块的过程调用。而模块中的资源都是私有的,只有通过公有过程才能被其他模块访问。而且,外部模块或者过程对本模块资源的修改受到严格的限制,唯一允许的操作就是“移动”(Move),不能随意对资源赋值。例如,Move中是不允许出现一个类似于MyCoin.setBalance()这样的接口,让其他用户有机会随意修改某个币种余额的。
除了受限的资源类型,Move模块中也允许定义非受限的成员,被称为非受限类型(Unrestricted Type),包括原生类型(boolean、uint64、address、bytes)和非资源类的结构体(struct)。这些非受限类型就没有那么严格的访问限制,可以用来描述与资产无关的其他应用类数据。从这个角度来说,Move语言理论上应该具有和Solidity同样的描述能力,但由于实际的去中心化应用中,总会涉及到资产类的数据,而任何引用了资源类型的结构体也都是受限的,能够真正脱离Move语言严格限制的机会并不多。所以在实际使用Move语言开发的时候,程序员一定会有一种戴着镣铐跳舞的感觉,代码出现编译时和运行时失败的可能也更大。通俗的说,用Move写代码不会让你感觉“很爽”,这就是安全性和可验证性的代价。想想你用C语言自己控制内存的分配和释放时,虽然有一种“我是上帝”的感觉,但也会时刻忧虑缓冲区溢出、内存泄露等潜在风险;而用Java语言开发,虽然你不再能够为所欲为的控制内存,但也不用担心这些内存安全性问题了。自由还是安全,往往是不兼得的。
在一个Libra的交易(Transaction)中,也可以嵌入一段Move代码,被称为交易脚本(Transaction Script)。这段代码不属于任何模块,是一次性执行的,不能再被其他代码调用。脚本中可以包含多个过程,通过main过程作为入口来执行,在其中也可以调用其他模块中的过程。这个设计有点类似比特币,而和以太坊完全不同。在以太坊中,一个交易本身是不能包含一段可执行代码的,只能部署新合约或者调用一个已部署的合约。我不太喜欢Libra的这个设计,由于任何Move代码都必须经过字节码验证器(Bytecode Verifier)的严格检查才能发布到链上,这种一次性代码的边际成本远远高于可复用的模块,会拖慢交易被确认的速度,降低系统的吞吐量。交易脚本并不是必须的,大部分现实场景都可以通过模块来覆盖,而且,它的存在还增加了Libra钱包的开发和使用难度,有机会的时候我会向Libra的开发团队提议取消这一设计。
下面我来看一下白皮书中的示例代码片段,直观感受一下Move语言。请注意,这段代码是Move中间语言的(IR),未来Move高级语言肯定会提供一系列语法糖,使代码更加简洁优雅。
public main(payee: address, amount: u64) {
let coin: 0x0.Currency.Coin = 0x0.Currency.withdraw_from_sender(copy(amount)); // 从sender余额扣除amount个Coin
0x0.Currency.deposit(copy(payee), move(coin)); // 将coin累加到payee的Coin余额中
}
这段代码是一个交易脚本,只有一个main过程,实现的是一个叫做Coin的代币转账逻辑,接受一个目标地址和转账金额作为参数,预期执行结果是把amount数量的Coin,从交易发起者的账户转移给address地址。
过程体只有两行,第2行声明了一个coin变量,类型是0x0.Currency.Coin。0x0是部署Currency模块的Libra地址,Coin是一个资源类型,属于Currency模块。这是一个赋值语句,coin的值是调用0x0.Currency模块的withdraw_from_sender()过程获得的。这个过程被执行的时候,会从sender的余额中扣除amount数量的Coin;
第3行调用0x0.Currency模块的另一个过程deposit(),把上面取得的coin这个资源累加到payee地址的余额中。
这段代码的特别之处在于,每个取变量右值的地方都有一个copy()或者move()。这就是Move语言最有特点的地方,它借用了C++ 11和Rust的move语义,要求在读取变量的值时,必须指定取值的方式,要么是copy,要么是move。用copy的方式取值,相当于把变量克隆出一份,原来的变量值不变,还可以继续使用;而用move的方式取值,原变量的引用,或者说所有权转移给了新的变量,原变量就失效了。C++中引入move语义的目的,是为了减少不必要的对象拷贝,以及临时变量的构造和析构,提高代码执行效率;而Move语言的目的,是为了通过更严格的语法和语义限制,来提高“资源”变量的安全性。在Move中,资源类型只能move,不能copy,而且只能move一次。
假如程序员的咖啡喝完了,状态很差,在写这段代码时出了一个bug,把第3行的move(coin)写成了copy(coin),会发生什么呢?
public main(payee: address, amount: u64) {
let coin: 0x0.Currency.Coin = 0x0.Currency.withdraw_from_sender(copy(amount));
0x0.Currency.deposit(copy(payee), copy(coin)); // move(coin) -> copy(coin)
}
由于coin是资源类型,不允许copy,Move的字节码验证器会在第3行报错。
再假如程序员写代码时,他的猫刚好从键盘上走过,踩到了Command和D键,于是,第3行代码重复出现了两次(第4行),又会发生什么呢?
public main(payee: address, amount: u64) {
let coin: 0x0.Currency.Coin = 0x0.Currency.withdraw_from_sender(copy(amount));
0x0.Currency.deposit(copy(payee), move(coin));
0x0.Currency.deposit(copy(payee), move(coin)); // Cat did it!
}
这一次bug更严重,会导致来源地址只扣除了一次金额,而目标地址却增加了双倍的金额。在这个场景下Move的静态检查就真正发挥作用了,由于第一次coin变量经过move取值后已经不可用,那么第二次move(coin)就会引起字节码验证器报错。
在以太坊中就没有那么幸运了,比如下面的代码:
pragma solidity >=0.5.0 <0.7.0;
contract Coin {
mapping (address => uint) public balances;
event Sent(address from, address to, uint amount);
function send(address receiver, uint amount) public {
require(amount <= balances[msg.sender], "Insufficient balance.");
balances[msg.sender] -= amount;
balances[receiver] += amount;
balances[receiver] += amount; // Cat did it again!
emit Sent(msg.sender, receiver, amount);
}
…………
}
以太坊是无法找到代码中多出来的一行balances[receiver] += amount;的(第11行), 每次send()被调用,Coin这个代币的总量都会凭空多出amount个。
读到这里,大家应该能够意识到,Move中最核心的组件就是字节码验证器。让我们来看看它是如何对一段Move字节码进行验证的,验证过程通常包括以下步骤:
控制流图构建:这一步会将字节码分解成代码块,并构建它们之间的跳转关系;
栈高度检查:这一步主要是防止栈的越界访问;
类型检查:这一步会通过一个“类型栈”模型来对代码进行类型检查;
资源检查:这一步主要针对资源类型进行安全性检查,防止资源被复制或消毁,并确保-资源变量被后续代码所使用。上文举的例子中的bug,就是在这一步被发现的;
引用检查:这一步参考了Rust的类型系统,对引用进行静态和动态检查。检查是在字节码级别进行的,确保没有悬空的引用(指向未分配内存的引用),以及引用的读写权限是安全的;
全局状态链接:这一步主要检查结构体类型和过程的签名,确保模块的私有过程不会被调用,以及调用的参数列表符合过程的声明。
Move的虚拟机,和EVM相似的地方比较多。它也是一个基于栈的虚拟机。指令集包含6类指令:数据加载和移动、栈操作/代数运算/逻辑运算、模块成员及资源操作、引用相关操作、控制流操作、区块链相关操作。
与EVM类似,每一条指令都会计算一个gas,耗光gas后代码会停机。Move中,一个交易的代码执行符合原子性,要么全部执行成功,要么一条也不执行。有趣的是,虽然Libra是一个标准的区块链账本结构,所有交易都是全局有序的,但Move语言本身支持并行执行,这意味着,也许以后Libra可以改进成类似Vite的DAG账本,提高交易并行处理的效率。
当前Move还处于一个比较早起的开发阶段,后续工作包括:
实现Libra链的基本功能,包括账户、Libra代币、准备金管理、验证节点的加入和移除、交易手续费管理、冷钱包等;
新的语言功能,包括范型、容器、事件、合约升级等;
提高开发者体验,包括设计一个人性化的高级语言等;
形式化建模和验证工具;
支持第三方Move模块。
本文如有错误,请读者不吝指正。想获取更多的细节,可以阅读白皮书或开源代码。
顺便说一句,这篇白皮书写的相当不错,概念准确,而且通俗易懂,没有使用特别形式化的描述或者复杂的数学知识,一个对区块链技术有所了解的读者完全可以一次读懂。这也从侧面反映出Facebook团队专业和务实的风格。
本文作者:刘春明,Vite Labs创始人,区块链技术专家,中国区块链应用研究中心常务理事。转载请注明出处。