写给开发者——从比特币脚本引擎到以太坊虚拟机

2,892 阅读24分钟

这个系列的目标受众是区块链开发者和CS专业同学

面对媒体对区块链相关技术的解读和吹捧,许多人一时不知所措。投资人、大公司都在FOMO(fear of missing out)的心理驱动下,争相宣布all in区块链。各路大咖坐而论道,谈论区块链技术的社会、政治、经济乃至哲学上的意义。人类对未知和不懂的东西有种天然的不安全感,作为一名开发人员,我认为克服焦虑(以及带来的投机心理)最好的方法是尽可能增加对底层的原理及实现的认知。

从技术角度来看,目前不论是比特币、以太坊,抑或是尚未正式上线的EOS、IPFS,都带有很强的实验性质,存在各种局限,而这种局限不可避免影响上层应用的开发。区块链应用也大多涉及金融、信用等重要领域,所以深入理解底层原理是对区块链开发者的一个基本要求,而不仅仅是跟着教程10分钟部署一段智能合约,特别是早期各种技术未成熟的情况下,生搬硬套稍不留心就会造成极大的损失。

本系列的第一篇文章,主要是以比特币为代表的加密货币架构(区块链1.0),和以以太坊为代表的可编程分布式信用基础设施(区块链2.0)的核心差异之一——是否支持图灵完备的语言,来看看区块链技术架构的演进。

比特币和以太坊的渊源:对币圈和链圈的人来说,Vitalik Buterin(1994年出生)是无可争议的大神。很多人可能不知道,V神作为早期比特币社区的活跃成员,一开始提议bitcoin需要开发通用的脚本语言来支持丰富功能的应用开发,但没有获得比特币开发团队的支持。于是重起炉灶,2013年发起以太坊项目,有了今天的繁荣的加密token、收藏品游戏、DAO。我们就先看看,V神不满的比特币脚本系统到底是什么样的?

Part I:比特币脚本引擎

交易

交易是在区块链世界里面有很广泛的含义,在加密货币应用中可以狭义理解为比特币额度在不同地址间的转移,即转账。转账是个历史悠久的行为,但转账技术一直在革新。 理解比特币转账模型尤其重要,因为比特币脚本引擎建立在该模型之上。

两种转账方式

1、简化下的传统中心式转账:alice(A账户)转账到bob(B账户)x元,银行需要原子化的操作balance[A]-=x,balance[B]+=x,当然隐含条件是alice完成了对A账户的认证。

2、一种解释比特币交易原理的说法: 网络中每个节点维护独立的数据库,记录着每个地址的余额,如果Alice(addressA的拥有者)想向Bob(addressB的拥有者)转账x元,她会在网络中广播出去"addressA gives X units to addressB",带上pubkeyA,用privatekeyA签名。每个节点收到后,校验成功后,在各自数据库中执行原子化操作balance[addressA]-=x,balance[addressB]+=x。(注:实际地址由pubkey生成,这里为简化省略)。

上面1在现实中占据主流,有成熟的扩展方案,但中心化不可避免带来成本、平台作恶等问题; 2的描述来自于b-money, an anonymous, distributed electronic cash system(这篇文章非常之重要,深刻影响了中本聪对比特币的设计),但在当时无法实践,因为重度依赖于一个同步、不受干扰的网络环境,否则保持一致性难度很大。而且这种分布式数据库提交问题(Byzantine Problem),现有的一致性算法paxos、raft(non-byzantine)包括pbft(byzantine)扩展性都无法支撑比特币上万的节点数。

比特币交易模型的设计

关于比特币交易模型最早来自于中本聪的 Bitcoin: A Peer-to-Peer Electronic Cash System中本聪实际提出了两种chain,大家现在一直说的区块链(chain of blocks)是显式的数据组织方式,另一个隐式的是交易链(chain of transactions)才是比特币价值流动的链条。

如图,最早的交易描述模型:

如果Alice(addressA的拥有者)想向Bob(addressB的拥有者)转账x元,她同样需要把这个交易签名后在网络中广播出去。不同的是,addressA的余额,并非存储在各个节点的数据库里,而是在别人给addressA转账的未花费交易输出中,即UTXO(unspent transaction output)。我们查询addressA的余额,实际得到的是所有收款地址是adressA的UTXOs的额度的求和。广播内容类似"addressA(combining UTXO1...UTXO3) gives X units to addressB",带上pubkeyA,用privatekeyA签名。 交易在网络中被确认后,Bob就会多了一个可用UTXO。如果他想花费这笔钱,需要证明自己拥有addressB对应的privatekeyB,那么Bob也用私钥签名。这样交易就成了一串签名的链条。

显然这里有三个问题:

1.如果任意的交易的input都需要某个之前交易的输出,那么最初比特币从哪里来?
所以在比特交易中,有种叫做coinbase的交易,就是我们所周知的挖矿奖励。比特币的产生就通过挖矿算法生成,这里的input来自于系统奖励。实际上还会校验coinbase是否是"mature"的,即该块是否经过足够的确认。在比特币中如果最终没有归入最长链,那么会作为orphan块被弃,奖励也作废。

2.判断一个交易输出是否是UTXO需要回溯整个区块链吗?
不需要,因为交易按照merkel tree的结构组织,决定了从整个区块链数据库中查询一个交易会非常低效。UTXOs专门存储在leveldb的数据库chainstate中,并且缓存在内存中。每当一个新的block生成,就会更新UTXOs集;当某个节点发生链重建现象,会回滚该过程。这里需要注意的是,UTXOs集不是待确认交易池(TxMemPool),而是所有待确认交易的input来源;UTXOs理论上也可以通过--reindex从整个区块链中重建。

3.Alice的账户余额来自于四个UTXO,分别是0.05,0.2,0.2,0.3,现在需要转帐0.6给Bob,怎么办?


理论上Alice可以三次转,但实际上很不明智,既要多付手续费,体验也差,所以交易Input可以包括多个UTXO,如何选择UTXO组合有专门的分析;多个Input之和不一定恰好等于转账金额Output1,还需要一个找零钱refund(Output2),当然还会有手续费fee(Output3),所以交易会包括多个Output。当然对于用户来说,只需要设定转账地址、额度、手续费,组合UTXO、找零等是透明的。

注:以太坊摒弃了UTXOs模型,采用类似于bmoney的账户范式。具体原因等到介绍以太坊虚拟机设计中再分析。

做了这么多铺垫,终于可以进入比特币的脚本设计了。

Script opcodes

比特币交易由一套脚本引擎(Script)处理。这里引用bitcoin-core源码interpreter.cpp里的一段注释:

/**
 * Script is a stack machine (like Forth) that evaluates a predicate
 * returning a bool indicating valid or not.  There are no loops.
 */

Script是一种类Forth、基于栈式模型、无状态的、非图灵完备的语言。 opcodes分为常量、流程控制、栈操作、算术运算、位运算、密码学运算、保留字等若干类,还包括3个内部使用的伪指令。下面举几个在后面的脚本中会出现的指令,全部的指令可参考官方文档源码

  • OP_0 ... OP_16: 将字面量值压入栈中。
  • OP_DUP: 将栈顶元素复制一个,压入栈中。
  • OP_ADD: 弹出栈顶元素和次栈顶元素,相加后压入栈中。
  • OP_EQUAL: 弹出栈顶元素和次栈顶元素,比较是否相等,相等则将1压入栈中,否则压入0。
  • OP_SHA256: 弹出栈顶元素,进行sha-256加密运算,结果压入栈中。
  • OP_HASH160: 弹出栈顶元素,先进行sha-256加密运算,再进行ripemd160摘要运算,结果压入栈中。值得注意的是,这是基于公钥生成address的过程的一部分。
  • OP_CHECKSIG: 弹出栈顶元素和次栈顶元素,这里分别是sig和pubkey;内部有个VerifySignature函数,验证签名和公钥是否匹配。
  • OP_CHECKMULSIG:栈内压入m个签名,n个公钥,逐一校验m个签名是否对应n个公钥的某个子集。

Pay-to-PubkeyHash(P2PKH)

上面Alice转载给Bob的例子,就是一个典型的P2PKH。中本聪在论文中只是给出了交易模型,下面看看更具体的实现。

如上图,Alice在转账给Bob前,Bob需要提供一个自己的收款地址,但实际P2PKH中使用的是Public Key Hash。这里简单补充下key生成过程,如下图,私钥单向生成公钥,公钥通过OP_HASH160指令生成160位的PKH(公钥哈希),PKH可以转成更可读用户使用的地址,但编码、校验过程等是双向的。所以提供地址等价于提供PKH。

下图,Alice转账给Bob的钱锁定在TX1 Output中,通过一个Pubkey Script。Bob如果尝试花掉这笔钱,他需要解锁这个PubkeyScript,通过证明自己是TX1 Output中Public Key Hash的私钥拥有者,提供一个Signature Script。

下面就是这两个脚本。

锁定脚本:
scriptPubKey: OP_DUP OP_HASH160 <pubKeyHash> OP_EQUALVERIFY OP_CHECKSIG
解锁脚本:
scriptSig: <sig> <pubKey>

上面包括在<>之间的为要压入栈中的数,push指令缺省。实际执行时,会将scriptSig和scriptPubkey连接起来,按照从左往右顺序运行脚本。

验证过程:
<sig> <pubKey> OP_DUP OP_HASH160 <pubKeyHash> OP_EQUALVERIFY OP_CHECKSIG

栈上的情况如下面两图所示。有汇编基础的同学,对栈式计算机模型的运作原理会很熟悉。

generation 奖励矿工

奖励矿工可以看作一种简化的P2PKH,区别在于交易的输入来自coinbase而不是某个UTXO。

Pay-to-Script-Hash(P2SH)

P2PKH设计比较简单,接受者Bob直接提供收款地址。实际的价值流通过程中,会涉及很多条件。为了满足更复杂的功能,BIP12中提出加入OP_EVAL指令(在程序语言设计中,eval意味着语言具备了元编程能力),并在之后由BIP16提出了更完善的交易标准P2SH。

收款方Bob需要先设计一个RedeemScript——提款脚本,再生成该脚本的Hash,提供给Alice。

Bob如果想花费该笔UTXO,则需要提供签名和RedeemScript,校验成功后执行RedeemScript的内容,满足条件后则成功解锁。

下面的redeemScript结合具体场景设计。后面结合智能合约的应用给出相应的例子。

锁定脚本:
Pubkey script: OP_HASH160 <Hash160(redeemScript)> OP_EQUAL
解锁脚本:
Signature script: <sig> [sig] [sig...] <redeemScript>

在P2SH交易中,由于Bob提供的是一段脚本的Hash,那么Alice实际上不知道这笔交易的细节,交易的具体内容需要Bob来设计。这就是所谓的*"moving the responsibility for supplying the conditions to redeem a transaction from the sender of the funds to the redeemer. They allow the sender to fund an arbitrary transaction, no matter how complicated, using a 20-byte hash"。*这在设计上也不是说没有争议的,但是在比特币的技术框架下,是一种以最小的改动支持更多的特性的路径。

比特币的智能合约

虽然一提起智能合约,人们更多会想起来以太坊。但正如前面提到的,技术发展是一脉相承。早在1997年Nick Szabó在开创性的论文 Formalizing and Securing Relationships on Public Networks中提出了智能合约的概念。比特币的脚本系统支持有限的智能合约的开发,主要通过P2SH交易实现的。

MultiSig 多重签名

BIP11提出了M-of-N多重签名交易。一个交易的解锁条件是预定指定的N个pubkey中的M个签名认证(M<=N)。P2PKH可以看作1-of-1的签名。多重签名在增加安全、托管交易等场景下十分有用。所以比特币中专门实现了OP_CHECKMULTISIG的指令。可以通过下面的脚本设计来实现。

锁定脚本:
Pubkey script: <m> <A pubkey> [B pubkey] [C pubkey...] <n> OP_CHECKMULTISIG
解锁脚本:
Signature script: OP_0 <A sig> [B sig] [C sig...]

如果使用P2SH交易,也可以设计成如下脚本。

锁定脚本:
Pubkey script: OP_HASH160 <Hash160(redeemScript)> OP_EQUAL
解锁脚本:
Signature script: OP_0 <A sig> <C sig> <redeemScript>
其中:
Redeem script: <OP_2> <A pubkey> <B pubkey> <C pubkey> <OP_3> OP_CHECKMULTISIG

Gavin Andresen写了一个2-of-3的多重签名交易的使用例子,十分详细,我就不搬运了。

Part II:以太坊虚拟机

区块链范式

Gavin Wood在黄皮书中将区块链系统抽象为基于交易的状态机:

公式(1)中S是系统内部的状态集合,f是交易状态转移函数,T是交易信息,初始状态即Gensis状态;
公式(2)中F是区块层面状态转移函数,B是区块信息;
公式(3)定义B是一系列交易的区块,每个区块都包括多个transaction;
公式(4)G是区块定稿函数,在以太坊中包括uncle块校验、奖励矿工、POW校验等。

这个数学模型不仅是以太坊的基础,也是目前大多数基于共识的去中心化交易系统的基础。
以太坊相对比特币的提升,本质体现在这个范式中的fS。它的核心理念——具备图灵完备和不受限制的内部交易存储空间的区块链。分别对应:

  • 功能强大的函数f,能够执行任何计算,比特币不支持loop;
  • 状态S记录任意类型的数据(包括代码),而比特币的UTXO模型只能计算出地址的可花费额度。

数据结构

在数据存储方面,比特币通过UTXO模型计算地址余额,不鼓励用户存入其他数据;通过P2SH脚本机制,理论上可以设计各种智能合约,但受限于脚本语言的表达能力,难以支持复杂的合约开发。这种设计对于加密货币来说是合理的。

以太坊为了支持记录任意的信息、执行任意函数,需要重新设计数据结构。

Merkle Patricia Trie

以太坊中重度使用Merkle Patricia Trie组织、存储数据,下面我们会看到,这个新的数据结构是通过对哈希树和前缀树的组合创新来达到目的。
约定:下面使用MPT来代替Merkle Patricia Trie。

Merkle Tree

又称hash tree:树的每个叶子结点是某个数据块的哈希值,而每个非叶子结点是孩子结点的哈希值。如图所示,这棵树不存储Data blocks本身。在P2P网络环境中,恶意网络节点如果修改了这颗树上的数据,将无法通过校验(Merkle Proof),从而保证了数据的完整、有效性。这依赖于单向哈希加密的性质。这种性质让它广泛应用在分布式系统的数据校验中,比如IPFS、Git等。

中本聪也巧妙利用该性质,设计了比特币的SPV(简化支付验证)功能。如下图,用户不需要运行完整的结点,只需要下载最长链的区块头数据,然后获取待验证交易对应区块的merkle树做校验。

Patricia Trie

又叫Radix Trie,是前缀树的空间优化变种:如果树上某个节点是其父节点的唯一子结点,则这两个结点可以合并起来。它在这里的应用是对长整型数据的映射,由某个20bytes的以太坊地址映射到其账户,形如<Address,Account>,Address会加密编码成16进制的数字——在Patricia Trie上,表现为非叶结点连成的路径。

比如,在Patricia Trie上存储<"dog","Snoopy">,"dog"会被编码为"64 6f 67",先找到根节点,则查询路线为root->6->4->6->15->6->7->value,value也就是一个指向"Snoopy"的hash。这种方式相对hash表的好处在于不会出现冲突;但如果不做优化,查询步骤太长。

改良点

为了提高效率,以太坊对树上结点数据类型进行了专门的设计。包括以下四类结点

  • null结点 代表空字符串
  • branch结点 17个元素的非叶节点,形如<i0,i1...i15,v>
  • leaf结点 2个元素的叶结点,形如<encodedPath,value>,encodedPath是地址加密编码后的长整型数字串的一部分
  • extension结点 2个元素的非叶结点,形如<encodedPath,k>,extension的作用是把没有分叉的路径上结点合并起来,节省空间资源
    如图,是一个简化的状态树(状态树后文很快会详细解释,这里不妨碍作示意图),右上角就是<地址,余额>的映射。prefix项的作用是辅助编码,可以忽略。4个账户的地址,按照MPT组织起来。其中所有的extension节点只是优化作用,都可以用多个branch结点替代。

使用MPT需要有后端数据库(以太坊中使用levelDB)维护每个结点间的连接关系,这个数据库叫做状态数据库。使用MPT的好处包括:(1)这个结构的根节点是加密的且依赖于所有的内部数据,它的哈希可以用于安全性校验,这是merkle树的性质,但和merkle树不存储数据块本身不同的是,MPT树结点存储了地址数据,这是Patricia树的性质(2)允许任何一个之前状态(根部哈希已知的条件下)通过简单地改变根部哈希值而被召回。

状态

上面在解释MPT时已经介绍了状态树的概念。以太坊中的世界状态(World State)的概念,通过MPT映射存储去中心化交易系统记录的任意状态。这对应了区块链范式中的S,是以太坊设计的一个核心概念。

如图,一个简化的区块中有三个root hash,对应三棵MPT。其中state root就是状态树的根哈希,它是地址(160bit)到账户数据(Account,序列化存储在levelDB)中。每次有效的交易都会导致状态变化,比如图中简单示意了Account175的balance从27变为45,而所有其他的账户多没有发生交易,那么block175224只需要新建Account175相关分支上的数据,而其他分支不需要复制!当然以太坊主网上新区块包含的交易大概为几十到几百不等,那么涉及的修改也会更多。关于这种结构性能上的讨论参考这篇文章查询最新的账户状态的入口应该是最新被确认的区块的状态树。

对以太坊的账户模型需要专门做个介绍。

Account

比特币使用UTXO模型计算余额,无法满足记录任意状态的需求。以太坊设计了Account模型,它会存储包括:
[nonce, balance, storageRoot, codeHash]
其中nonce是交易计数器,balance是余额信息,storageRoot对应另外一个MPT,通过它能够在数据库中检索到合约的变量信息,codeHash是代码hash值,创建后不可更改

账户分为两种

  • 外部账户(externally owned accounts)
    外部账户由私钥控制,对应Account模型里,storageRoot、codeHash并不存在,也就是不会存储、执行代码。如果只有外部账户,那么以太坊只能支持转账功能。
  • 合约账户(contract accounts)
    合约账户可以通过外部账户发起交易创建,也可以是由另一个合约账户创建。合约账户在收到消息调用时,会加载代码,通过EVM执行相应的逻辑,修改内部存储的状态。

交易

在UTXO模型下,交易本质上是(通过签名的数据)对input的解锁和对output的锁定。在Account模型下,交易分为两种:

  • 创建合约,通过代码创建新的合约
  • 消息调用,可以转账也可以触发合约的某个函数

两种类型的交易都包括以下字段:
[nonce,gasPrice,gasLimit,to,value,[v,r,s]]

  • nonce: 账户发出交易数量
  • gasPrice,gasLimit: 用于限制交易执行时间,防止程序死循环
  • to:交易的接受者
  • value:转账额度,如果是创建合约,就是捐赠给合约的额度
  • v,r,s:交易签名相关数据,可以用来确定交易发送者

合约创建还需要:

  • init:一段不限大小的字节数组表示的EVM代码,仅在合约创建时运行一次;init执行后返回body代码片段,之后的合约调用都会运行body代码内容。
    合约账户的地址由sender和nonce共同决定,所以任意两次成功的合约部署得到的地址都是不同的。从上图能看出,代码和状态的存储是分开的。实际上编译后的字节码会存储在一个virtual ROM中,且不可修改。

消息调用还需要:

  • data:一段不限大小的字节数组,表示消息调用时的输入
    消息调用会修改账户的状态,可能是EOA账户也可能是合约账户。

交易既可以由外部账户发起,也可以由合约发起。比如第5228886区块包含170个交易和7个内部合约交易。

区块

以太坊的区块了加入更多的数据项,相对比特币要复杂很多,但其实本质上区别不大。比如加入了叔链哈希,优化激励措施,这是为了支持挖矿协议;区块本身还会有大量的有效性验证、序列化。这些内容不在本文主题范围,不深入讨论。

参见上面这张图的右半部分,一窥以太坊区块如何组织数据,能看到MPT树的大量使用;左半部分涉及到EVM,将会是接下来的重点。

EVM设计与执行

以太坊虚拟机(Ethereum Virtual Machine)是执行以太坊的状态转移函数的运行环境。 有个简单的问题,以太坊是否可以不专门开发一款底层VM,而是复用Java、Lisp、Lua等呢?理论上是完全可以的,Corda项目就完全基于JVM平台开发。但是更多的区块链项目会选择专门开发底层设施,包括比特币的脚本引擎。以太坊官方给出的解释:

  • 以太坊的VM规格更简单,而别的通用VM有很多不必要的复杂性
  • 容许定制开发,比如32bytes的字
  • 避免其他VM带来的外部依赖问题,可能导致安装困难
  • 采用其他VM,需要做完全的安全性审查,权衡下不一定能省多少事

内存模型

EVM也是基于栈式计算机模型,但除了stack外还涉及memory和storage:

  • stack 栈上元素大小为32bytes,这和一般的4bytes,8bytes不同,主要是针对以太坊运算对象多为20bytes的地址和32bytes的密码学变量;栈的大小不超过1024;栈的调用深度不超过1024,主要防止出现内存溢出。
  • memory 虽然运算都在栈上进行,但临时变量可以存在memory里,memory大小不做限制
  • storage 状态变量都放在storage里,不像stack和memory上的量随着EVM实例销毁消失,storage里面的数据修改后都会持久化
    如图,是一个EVM架构的示意图,这种设计对以太坊应用开发有着深远的影响,包括设计模式和安全考量。 一个经典的问题是合约的升级
    合约部署后编译成字节码存储在virtual rom中,代码是不可修改的,这对很多DAPP来说是严重的制约。一种思路是,将代码分布在不同的合约中,合约间调用通过存储在storage中的地址来进行,这样实现了实际上的合约升级操作。

执行模型

EVM准确来说是一个准图灵机,文法上它能够执行任意操作,但为了防止网络滥用、以及避免由于图灵完整性带来的安全问题,以太坊中所有操作都进行了经济学上的限制,也就是gas机制,有三种情况:

  • 一般操作消耗费用,比如SLOAD,SSTORE等
  • 子消息调用或者合约创建而消耗燃料,这是执行CREATE、CALL、CALLCODE费用中的一部分
  • 内存使用消耗费用,与所需要的32bytes的字数量成正比

下图展示了EVM执行的内部流程,从EVM code中取指令,所有的操作在Stack上进行,Memory作为临时的变量存储,storage是账户状态。执行受到gas avail限制。

现在结合EVM我们再来看看之前介绍的交易的执行细节。正如区块链范式定义的,T是以太坊状态转移函数,也是以太坊最复杂的部分。所有的交易在执行前,都需要先经过内部的有效性验证:

  • 交易是RLP格式数据,没有多余的后缀字节;
  • 交易的签名是有效的;
  • 交易的随机数是有效的;
  • 燃料上限不小于实际交易过程中用的燃料;
  • 发送者账户的余额至少大于费用v0,需要提前支付;

下图是消息调用的过程,每个交易可能会形成很深的调用栈,交易内部由不同的合约之间的调用。调用通过CALL指令,参数和返回值通过memory传递。

错误处理

EVM在合约执行时会发生若干种错误:

  • 燃料不足
  • 无效指令
  • 缺少栈数据
  • 指令JUMP JUMPI的目标地址无效
  • 新栈大小大于1024
  • 栈调用深度超过1024

EVM的错误处理有个简单的原则,叫做revert-state-and-consume-all-gas,即状态恢复到交易执行前的checkpoint,但消耗的gas不会再退还。虚拟机把错误全看作是代码出错,不作特定的错误处理。

EVM分析工具

关于EVM分析的工具可以参考Ethereum Virtual Machine (EVM) Awesome List

类EVM的图灵完备虚拟机(WIP)

完整的EVM规格是很复杂的,但具备一定的汇编基础和简化模型的能力,实现一个类EVM的虚拟机是可以尝试的挑战。等有空我再把自己的实现放上来吧。有兴趣的同学可以自己动手试试。

参考

1.A Next-Generation Smart Contract and Decentralized Application Platform
2.ETHEREUM: A SECURE DECENTRALISED GENERALISED TRANSACTION LEDGER
3.Design Rationale
4.Stack Exchange: Ethereum block architecture
5.Go Ethereum
6.evm-illustrated
7.Diving Into The Ethereum VM