Web3.0描述的是一种在零信任网络上进行交互的能力,它旨在创建一个全新的信任体系,并改变人与人、人与机构、机构与机构达成协议的方式,用户不需要相信来自任何实体的任何承诺,就可以依赖确定性的软件逻辑,完全按照程序执行协议,这将大幅减少建立信任的成本,提高社会运作的效率。而这一愿景,其实很大程度上依赖于区块链以及智能合约技术。

智能合约的概念首次出现在1994年的时候,在美国的密码学家、计算机科学家Nick Szabo的一篇文章里,他是这样定义智能合约的:

A smart contract is a computerized transaction protocol that executes the terms of a contract. The general objectives of smart-contract design are to satisfy common contractual conditions (such as payment terms, liens, confidentiality, and even enforcement), minimise exceptions both malicious and accidental, and minimise the need for trusted intermediaries. Related economic goals include lowering fraud loss, arbitration and enforcement costs, and other transaction costs.


Nick Szabo认为要使智能合约发挥应有的价值,它需要可验证、可观察、可执行,智能合约可以降低法律的障碍,削减交易的成本,并会成为自由市场经济的基本组成部分。


从可编程性上来看,比特币支持智能合约,但是比特币智能合约采用的脚本语言叫做script,它非常简陋,比如它就不像我们平时用的Javascript、Golang等等有全局变量、局部变量这些概念,唯一能访问的内存空间只有一个堆栈。

我们可以用一个简单的脚本来体会一下这个所谓的面向堆栈语言的设计,比如下面这个程序:

它会首先把1压入栈,然后把2压入栈,OP_ADD是一个操作码,它会从栈里面POP出两个元素并进行求和,再将求和结果压入栈,所以1 2 OP_ADD实际上相当于1+2=3,然后把3压到栈里面。在OP_ADD操作码的后面,又把一个3压入到栈里了,那么现在栈里面有两个3了。OP_EQUAL操作码会从栈里面POP出来两个元素,比较它们是否相等,相等则执行成功,否则执行失败,所以这段脚本是用来验证1+2是不是等于3的。

需要注意的是,比特币脚本是一个图灵不完备的语言,比如首先它被刻意设计为不支持循环。听说过停机问题(Halting Problem)的同学应该知道,停机问题描述的是对于任意输入的程序w,是否存在一个程序P可以判断w会在有限时间内结束或者死循环。图灵在1936年的时候就通过对角论证法证明了停机问题在图灵机上是不可判定的问题,也就是说,如果比特币脚本被设计成图灵完备的,那么比特币程序在执行这个脚本的时候,是没有办法判断这个脚本是不是会陷入到死循环的,在区块链的设计里,如果一个交易脚本触发了死循环,那么所有的节点都会陆续陷入到死循环,整个区块链网络将陷入瘫痪。

但很多问题其实学术上无解,但工业上总是可以找到折衷的版本。那我们想一想这个问题有什么折衷的办法可以解决。

图片


第一种方式就是非图灵完备,像比特币脚本一样,不支持循环、跳转这些指令。比特币白皮书的题目是《Bitcoin: A Peer-to-Peer Electronic Cash System》(基于对等网络的电子现金系统),所以它在设计的时候就被定义为了一种去中心化的数字货币,从社区的基调里也可以很明显的看出,比特币仅仅是在尝试成为一种新的货币或者储备货币,因此从设计预期上来看,比特币脚本被设计成非图灵完备其实无可厚非,但与比特币不同,以太坊在设计的时候,就期望成为一个全新的开源分布式计算平台,因此需要有更好的办法来解决这个问题。

容易想到的另一种方式是计时器,用时间作为标准来衡量一个脚本是否陷入到了死循环。IBM研发的区块链项目Fabric其实就是用超时时间来做的。但是如果按照时间进行计费的话,需要注意在分布式系统中,由于不同节点的性能差别可能很大,导致同一个交易在不同机器上的执行的耗时差别可能也会很大,如果大量的节点在判断某个交易是否超时的时候存在不一致,就可能引起共识上的问题。说到这里我想引申一下,区块链里面的编码其实是一门非常严肃的科学。

我们举两个例子,一个是我们刚刚提到的时间,大家在Golang里获取时间会很自然的使用time.Now,计算时间差值会用time.Since或者time.Sub。以太坊最流行的版本是用golang写的,在一些比较早期的版本里,比如1.9的版本里,它用的其实不是标准库的time,用的是monotime,有的同学可能听说过闰秒,是这样的,为了确定时间,世界上有两种时间计量系统,一种是基于地球自转的世界时,一种是基于基于原子振荡周期的原子时,因为这两种时间尺度对秒的测量方法是不一样的,所以随着时间的推移,两个计时系统会出现差异。

我们现实世界使用的大多数都是协调世界时,就是UTC,协调世界时是以原子时为基础的,然后尽量去接近世界时(就是基于地球自转的那个时间)。国际计量大会规定的是,当这个差距即将到达0.9秒的时候,就会向全世界发布公告,让协调世界时在某天的最后一分钟减少或者增加一秒,来尽量接近世界时,这个修正就叫做闰秒。给我们的直观感受往往就是23:59:59这一秒,持续了两秒的时间,相当于那一分钟有61秒,如果Linux上如果安装了NTP,就是这样处理的。

有些同学可能没有意识到这可能产生什么问题,要知道很多随机算法、以及uuid生成算法,是依赖于强依赖于时间的,还有很多数据库里事务的实现也依赖于时间戳,如果这一秒重复了,很有可能对这个世界上的某些系统产生灾难性的影响,比如2015年实施过一次闰秒,直接导致美国第二大期货交易所(美国洲际交易所)停牌一个小时,在2017年进行闰秒的时候,cloudflare使用golang开发的一个DNS系统,它利用时间差值来做评估性能进行负载均衡,在计算时间差值的时候,就因为闰秒返回了负值,下面是当时的代码:

这直接导致程序panic,影响到了102个数据中心。Golang在2017年解决了这个问题,在Go 1.9之后的版本里,标准库time会在必要时候透明的使用单调时钟。很多云厂商也会帮他们的客户解决这个问题,比如Google的解决办法是,把增加的一秒分解到那一天之内的若干毫秒里。相比之下,以太坊很早就开始有意识的使用单调时钟来尽量消除闰秒对于共识的影响了,从这个技术的细节上可以看到,区块链的研发要求的不仅仅是足够严谨的逻辑,还要有足够丰富的技术广度,不然作为开发者,可能根本意识不到会发生什么灾难。

另一个例子是和数据的序列化有关的,大家知道,数据在存储或者传输过程中一般都需要进行序列化,以太坊底层用的序列化库叫RLP(递归长度前缀编码),是一套独立的解编码算法,能不能直接用JSON或者Protocol Buffers呢?区块链里面要求的是对于同样的内容,所有节点的结果都是一样的,这样才能互相验证,形成共识。在这样的前提之下,一个好的序列化算法要求字节完美一致,就是对于一个输入,只能确定的得到一个输出,对于一个输出,只能由这一种输入得到,这才是在共识场景下优秀的序列化算法。首先,像JSON这种编码方式就达不到这个要求,它的key是无序的,而且在编码后存在大量的冗余字符,如果以太坊当时选用了JSON作为编码方式,那么现在在存储方面的成本就得至少提高个30%了。Protocol Buffers的问题在于它并不保证对于同一个输出,只能由一种输入得到,而且在JavaScript这种弱类型语言里,所有数字都是number类型,底层用浮点数实现,在一些高精度场景就可能导致编码结果有一定的差异。

如果不满足我们提到的字节一致性可能出现什么问题?比如在比特币里就发生过这样的问题,第三方可以把交易进行变异,让它功能相同、仍然有效,但是具有不同的交易哈希,在JSON里想到做到这样的效果就太容易了,比如更换key的顺序,或者加一个重复的key都可以,这就会大幅增加钱包软件的复杂程度,同时还可以滥用这个漏洞,在挖矿中进行不正当竞争(比如可以滥用它使得依赖于未变异且未确认交易的长链无效)。以太坊吸取了这些教训,在RLP编码的设计里,就是确定的要求两件事情,一个是简单,所有语言都很容易正确的实现它。另一个是字节完美一致,输入输出一一对应,比如在RLP中就不支持编码map这种数据结构,必须把它转换成切片或者数组,才允许进行序列化。

我们回到对智能合约图灵完备性的讨论上来,上面提到可以基于时间进行死循环的检测,但是因为不同机器性能不一样,可能会有一些问题,还有没有更好的方式呢?以太坊采用的方式是计价器,就是在智能合约执行的时候,每执行一个指令,就消耗一定的配额,等超过了一定的限制,就可以认为程序已经进入了死循环,强制终止程序的运行。就像现实世界不存在永动机一样,也像是汽车运行时候需要耗费燃油,这种方式是不是比基于时间的限制更精确一些?以太坊采用的就是这种方式,在以太坊中智能合约采用的语言是Solidity,这个计价器的机制叫做Gas费,就是燃油。用户调用智能合约的写方法,是以发送交易(Transaction,也可以理解为数据库里的事务)的形式进行的,需要支付一定的交易费:

其中,Gas Price是单位Gas的执行成本,就像是汽车的油价一样,代指每升油多少钱,它会随着网络中算力的供需关系而变化。Gas Used是这笔交易使用了多少个单位的Gas,就像是汽车行驶时使用了多少升油,它是由负责执行交易的节点计算出来的,在进行智能合约调用的时候,每执行一个指令,这个Gas Used就会增加一些,直到智能合约执行完成,或者到达Gas Limit。Gas费的设计不仅仅可以防止智能合约陷入死循环,还可以防止垃圾交易,它是以太坊经济模型中至关重要的组成部分。

因此,用户执行智能合约的成本,与智能合约中方法的具体实现密切相关。就像在数据库的设计中,Clickhouse为了提高性能,会使用SIMD来提高CPU的运算效率。优秀的智能合约设计者,也会选择最优的算法,或者使用一些内联汇编的手段,来尽量降低用户与区块链的交互成本。我们在实际的应用中,就研发出了动态计算资源定位符的优化方式,通过这种手段可以降低50%左右的数据存储费用,这项技术也申请了国家专利。


以太坊提供了面向合约的高级编程语言Solidity,以及以太坊虚拟机(environment virtual machine,简称EVM),EVM的作用是提供智能合约运行的沙盒环境,并在以太坊上执行特定的操作码。

图片


在虚拟机实现上,以太坊的智能合约与比特币脚本一样也是基于栈的,与基于寄存器的虚拟机相比,这种方式的好处是可以无视具体的物理机器架构,可移植性强并且实现简单,缺点是速度比较慢。

EVM的操作码长度为1个字节,目前已经存在140个操作码,包含了栈操作、数学运算、内存、硬盘以及一些控制符等,下面是一些常见的操作码:

图片


你可以在官网看到完整的映射表。我们结合上面的这个映射表,以及下面这段用Solidity编写的智能合约代码,来体会一下EVM的设计:

为了减少编译器优化的干扰,在这里使用0.1.5版本的编译器编译上面的Solidity源码,得到部分的字节码是这样的:

根据上面的字节码映射表,可以将这段字节码转换为以下的操作码:

将以上操作码简化,其实就是这样一段EVM操作:

首先将0x1、0x2分别压入栈,ADD操作码会从栈顶POP出两个元素进行求和并压入栈,即将0x3压入栈,PUSH1会将0x3压入栈,EQ将栈顶两个元素POP出来进行比较,结果相等则会将0x1压入栈。我们将这段简化后的EVM操作码转换为字节码就是:

在 evm.codes 中进行执行:

图片


可以看到栈中最后结果是1,符合我们对“1+2”的结果等于3的预期。

用户在发起基于EVM的智能合约的部署和调用时,依赖于采用C++开发的solc工具,需要在本地环境先将源码或者函数调用编译成我们上面展示的字节码,再将它们发送到区块链上。

这种设计带来的好处是很明显的。一方面,各种语言以及各种版本的区块链,无需实现复杂的Solidity语法树解析以及编译,只需要实现一百多个操作码即可完成对Solidity的支持。另一方面,Solidity语言在迭代的时候心智负担更低,只要不涉及到操作码变动,大部分在语言层面上的调整都是对EVM虚拟机无感的,不会引起区块链分叉。这使得以太坊智能合约可以实现高速的版本迭代。

目前大量的区块链项目都是EVM兼容的,Solidity语言以及关联生态表现出了强大的生命力,这与这些曾经做出的正确的设计决策息息相关。


下面我们以白名单机制为例,来体会智能合约开发中的设计权衡。在智能合约的很多应用场景中都会用到白名单机制,比如在一些数字藏品的售卖中,就可以通过白名单机制来实现预售,想要获取白名单,用户往往需要参与项目方发起的一些社区任务,白名单构建的过程也是社区构建的过程。在实现上最简单的方式其实就是直接在链上维护一个字典,储存白名单用户的列表:

这种方式的好处是简单明了,对开发者来说实现成本也很低,但在区块链中直接存储数据的成本是很高的,如果需要将上万个地址全部录入到链上,通常需要为此支付数万美元的成本。

还有一种比较常见的方式是基于签名实现白名单,它的基本原理是“经过私钥签名的信息可以使用公钥进行验证”,所以项目方实际上只需要准备一个私钥,把白名单用户的地址使用私钥签名之后,把签名分发给用户就可以了。

用户在执行智能合约操作的时候,需要额外地将签名作为参数传递到智能合约里,智能合约根据签名以及原始信息(这里就是用户的地址)恢复出签署者,如果与我们私钥对应的签署者匹配,那么这个用户就是白名单用户。

这种方式不需要项目方将大量的用户地址录入到区块链中,从而可以节省大量的存储成本,它的另一个好处是项目方后续拓展白名单列表的成本也很低,只需要在链下进行签名,将签名发给用户即可,但同时这也是它的缺点,即用户无法有效的验证白名单列表是否篡改或者增发过,这种方式不够去中心化。

目前我最为推崇的方式是基于有序默克尔树(Sorted Merkle Tree)实现白名单。默克尔树从叶节点开始构建树,首先把所有节点的哈希值分别计算出来,然后再两两的计算哈希,直到计算到根哈希。有一个很明显的好处是,只需要比较根哈希,就可以判断两棵树是否相同。这个特性在分布式系统中用的也比较多,比如在Cassandra里,就利用默克尔树来做数据的校验和修复,因为基于默克尔树可以以二叉搜索的方式快速检索到哪些数据损坏了。

图片


还有一个重要的特性就是可以基于默克尔证明,用较低的成本证明一个节点存在于这棵树中:比如想要证明H(E)存在于这颗树里,只需要额外提供黄色节点的哈希就可以了,因为只需要这些节点的哈希值就可以计算出整棵树的根哈希了,如果通过H(E)加上黄色节点计算出的根哈希值,等于真实的根哈希值,那么就证明H(E)确实存在在这棵树里的 。这个特性用在P2P网络中的话,在一些场景下是可以大幅降低网络成本的,因为只需要log(n)个哈希值就可以完成一些校验工作了。在白名单功能的开发中,项目方把用户的地址排序后,依次加入到默克尔树中,最终可以得到一个树的根哈希,树中的每个叶节点都可以开出一份默克尔证明,这样项目方把根哈希放到合约里,用户拿着他们地址对应的证明就可以在智能合约中进行白名单的验证了。

下面是通过typescript生成默克尔证明的代码。在构造函数里把所有的白名单地址传进去,通过getProof就可以获取到给定地址的默克尔证明了,通过getRootHash可以拿到默克尔树的根哈希。

下面是通过Solidity验证默克尔证明的代码:

因为默克尔树的树根是由所有节点经过一系列的哈希计算得到的,所以只要树根没有变,那么这个白名单就没有篡改,项目方可以把白名单中的所有地址公示出来,让用户可以随时重新构建这棵树,来对比链上使用的树根进行验证。如果需要调整白名单列表,项目方也只需要更换一下智能合约中的树根。这种方式同时兼顾了成本和去中心化,是比较推荐的实现方式。


由于大部分基于EVM实现的智能合约在部署完成之后,字节码都是不可变更的,这使得难以进行用户无感的缺陷修复以及功能调整,当想要修复智能合约中的错误时,项目方需要:

  1. 部署新版本的合约。

  2. 手动将旧合约中所有的状态迁移到新合约。

  3. 联系所有的用户,说服他们使用新合约。

  4. 因为用户的迁移往往是缓慢的,需要考虑新旧合约共存时的业务兼容性问题。

除了这个问题,随着数字藏品业务的高速发展,智能合约标准化管理的重要性也越来越得到体现,进行工程化改造之前,我们在智能合约的管理上面临以下的问题:

  1. 没有标准化的CI/CD流程,业务研发需要统一本地的 solc 和 abigen 版本,自行进行编译。

  2. 合约的原始代码、ABI、字节码、编译后的Go文件等混合在业务项目中,缺少独立于业务代码的版本及权限控制。

  3. 流程上缺少标准的Code Review机制,难以把控合约源码的质量以及缺陷追踪。

  4. 缺少可升级合约的部署、缺陷检测、升级能力。

因此,我们独立设计了contracts仓库,用于实现智能合约的生命周期管理:

图片

合约仓库基于hardhat框架来支持智能合约的测试、编译,同时规范以下开发流程:

  1. 业务开发者在开发分支向 contracts 项目提交合约代码,并创建MR。

  2. 负责合约代码Review的同学对MR进行Code Review,通过之后进行代码的合入。

  3. 合约代码提交后,触发 contracts 项目配置的 CI/CD Webhook。

  4. CI/CD系统根据流程配置,进行合约代码的集成测试、Lint检查以及ABI、字节码、客户端代码编译。流程通过之后将会把编译后的代码推送到 contracts-go、contracts-artifacts 等项目。

  5. 开发同学引用 contracts-go 中的代码进行开发。

可升级合约通常有两种方案,一种是基于代码拆分实现的逻辑存储分离模式,一种是透明代理模式。为了减轻业务的开发负担,在大部分业务场景上,我们都选择采用透明代理的模式。它类似于通过网关实现后端服务的平滑升级:用户与代理合约进行交互,请求会被转发到逻辑合约中。业务逻辑写在逻辑合约,当业务需要升级时,只需要更新代理合约中的逻辑合约地址即可。

更具体地来说,代理合约对逻辑合约的调用基于委托调用(delegate call),这种调用方式可以做到调用逻辑合约的代码,但是执行环境是在代理合约,这使得本应在逻辑合约中发生的所有的状态存储、变更都是发生在代理合约的。这种方式带来的效果是,在我们升级了逻辑合约之后,旧合约中的存储状态,可以直接被新合约继承,从而达到无缝升级的效果。

为了更好的管理可升级合约,我们开发了合约管理服务,开发同学可以基于contacts仓库:

  1. 提供合约路径、参数以及commit id进行可升级智能合约的部署。在合约部署时合约管理服务会进行主合约与父合约可升级性的检测,比如构造函数是否完成了可升级改造。

  2. 提供合约路径以及升级前后的commit id进行合约的升级。在合约升级前会进行破坏性变更(比如存储布局变更)的检测,防止因为强行升级引起智能合约产生无法恢复的错误。

通过工程化改造,在智能合约的管理上我们实现了以下目标:

  1. 业务代码与合约代码的分离,进行独立的版本控制以及权限管理。

  2. 标准化合约代码的修改流程,代码合入需要链开发同学Code Review。

  3. 标准化CI/CD流程,代码合入后自动进行测试、Lint、Go代码生成等。

  4. 支持可升级合约的版本控制、部署、缺陷检测以及升级。


做中间件的同学可能对Paxos、Raft,或者像ZK的ZAB听的比较多,但是这类共识算法其实都是基于节点不作恶的基础上考虑的,区块链里的共识需要解决拜占庭将军问题,在比特币和以太坊中它被设计为PoW,就是工作量证明,它需要节点耗费大量的算力来求解数学问题,从而争夺记账权。

图片


比如比特币的数学问题,就是在区块数据(上图以abcde为例)的基础上,附加一个递增的nonce值,再进行sha256哈希,如果结果包含的前缀零数量满足区块难度的要求,就可以获得出块的权利,赢得区块链网络奖励。由于sha256哈希算法具有谜题友好性,几乎不可能刻意构造一个数据,来让其哈希值等于特定值。所以从本质上来看,PoW共识机制就是一种可以验证的加权随机算法,

那细想之下,矿工之间的竞争本质上比拼的是什么?拥有的机器越多,配置越好,算力就越强,获得记账权的概率就越高,其实本质上还是在比拼资本。挖矿浪费电力,矿机还有折旧成本,那能不能不挖矿,直接根据资产进行加权随机呢?

PoS共识机制就是这样的思想,它的全称是Proof of Stake,就是权益证明,想要参与出块,获得区块链网络奖励,直接把资金抵押到区块链上就可以了,算法根据质押的金额进行加权随机,来决定谁有出块的权利,这种方式是不是更加高效。

图片

高能链共识算法就是借鉴了PoS(Proof of Stake)和PoA(Proof of Authority)的共识理念,并对拜占庭容错算法PBFT进行了优化。共识流程如下:

  • 开始出块时,会选择一个proposer,在Propose阶段提交一个proposal,即block;

  • 在Prevote阶段,节点会对收到的proposal进行投票,如果超时未收到则投票给空提案;

  • 在Precommit阶段,当节点收到+2/3节点对同一提案的prevote投票后,会对提案进行precommit投票;

  • 如果搜集到+2/3 precommit投票,则进入commit阶段;否则需要再次进入Propose阶段,重新进行proposal的提交。

上述流程对PBFT的三阶段进行了优化,降低了网络复杂度,提高了共识效率。

此外,由于拜占庭类共识算法节点之间的投票权重相同。为了便于后期联盟链链上治理等功能,高能链共识算法融入了PoS和PoA的共识理念:

  • PoS理念:共识节点的出块权重,根据其质押的权重成正比。质押权重越大,出块概率越高;同时此质押权重也是共识节点参与链上治理的重要凭证,与其投票权重成正比;

  • PoA理念:共识节点集合增加白名单准入机制,非白名单节点无法参与网络共识。同时白名单节点的更新与删除通过链上治理的方式完成。

高能链共识算法,通过对PBFT的优化,并借鉴了PoS和PoA共识算法的理念,提出了适合自己的联盟链共识算法。



我们是高能链的技术团队。高能链是为新一代应用、文化、游戏以及数字资产构建的数字生态,旨在打造开放创新,包罗万象的数字原生社区。它的特点是:

  1. 高效节能的共识算法

  2. 社区治理的去中心化架构

  3. 完备的智能合约能力

  4. 合规的链上内容平台

  5. 丰富的跨链能力

我们的愿景是提供数字资产上链渠道,联合多元化应用共建生态,实现数字资产跨应用流通,为用户提供多样化使用场景和展示舞台。