FunFair:状态通道合约的参考实现,Part-1

引言

过去的两年半以来,FunFair 一直在开发一款完整的端到端区块链产品,以状态通道为核心。该产品的首个原型实现于 2017 年 6 月部署完成。2018 年 8 月,我们所发布的基于该技术的一款(有可能是全网第一款)商业应用掀起了热潮。

我们打算从本文开始披露我们的核心技术的具体运作方式,将更多技术细节公诸于众,最后实现开源。

该项目最核心的部分是,包含了我们独有的状态通道实现的以太坊 Solidity 合约。我们把这个状态通道实现称为命运合约(Fate Channel),因为它内嵌了具有确定性但是无法预测的随机数生成器(RNG)。就当前部署的版本而言,我们编写的代码既复杂,又混乱难懂;我们已经花一些时间重写了代码,以可读性为首要考量对其进行了重构,而且还有一个额外的好处,就是能更加清晰地展现平台的扩展方式——我们会在本文中提到这段代码。

首先,我们要说清楚的是,目前部署的是整个状态通道概念实现的 1.0 版本。该实现可以为参与双方开设双向的状态通道,具有完备的功能,而且允许参与方提出争议,不过我们还没有开始涉足虚拟通道和中心模型等活跃的研究领域。我们的首要考量是对外发布具有实用性的独立实现;不过,在重构代码的过程中我们会尽可能将其一般化。这样一来,如何在该实现中集成 Guardians/ Watchtowers(如 PISA )之类的平行研究就变得一目了然。

我们还发现,其他状态通道的开发者已经开始相互合作,尝试将该技术的一些方面标准化。本文有两个目的——一方面,与任意标准进行交叉对照,看看我们是否应该采用它们;另一方面,看看本文所述概念是否应该纳入这些标准之内。

最后要强调的是,文中涉及的代码是一个参考实现,尚未经历开放测试——我们会在适当的时候这么做,并将该代码应用到现有产品上。目前先不要将该代码投入实际应用!我们之后还会会更新并改进该代码,等准备就绪之时再发布出来。

状态通道概述

在阅读本文之前,最好对状态通道的概念有所了解。有很多非常优秀的论文和讨论值得一读。我们对状态通道代码的定义是下面这个函数的实现:

advanceState(state, action) => newState

这个函数的意思是,取一个状态,对这个状态进行某项操作,然后返回一个新的状态。整个过程不会产生副作用(side-effect)。

状态通道是一个允许两方乃至多方通过状态机来更新共享状态的协议;该协议可以确保状态更新通过协同的方式发生在链下,同时又因为得到相同的安全属性支持而仿佛是上链交易一般。

在传统的设置中,多个参与方将资金(意思包含 “一切有价值的东西”)提交至链上的状态通道合约。在链下交互的过程中会不断发生状态更新,等到将来的某个时间点,就最终状态达成共识之后,该最终状态会被提交到链上,将链上的资金释放回参与方手中。

在状态通道实际实现的开发过程中,我们的主要精力还是放在如何解决故障上面。状态通道需要参与者在线合作——如果出现掉线或有争议的情况该怎么办?我们在本文中深入探讨了 Dispute(争议)和 Dispute Resolution(争议解决)。要注意的是,“Dispute” 一词在研究者社区中本身就存在争议——在适当的时候会用更合适的词来替代它!

目前,很多关于虚拟通道(通道内的通道)等技术的研究正在进行之中,这意味着可以通过更加通用和长远的方式一次性将更新后的状态提交到链上,利用这些来自链下的状态更新可以开启或关闭单条状态通道。本文不会讨论具体方法,仅以此作为对基本概念的延伸。

其它研究还包括如何解除状态通道对——再强调一遍,本文对此不做讨论,不过我会谈一谈如何将状态通道技术引入我们的系统。

代码

本文中提到的代码可以在这条链接中找到。本文的结尾处放了关于代码的一些说明——还有另外一些是关于本文剩余内容的说明。

开启状态通道(第一部分)

假设有两个参与者想要开启一个状态通道。然后他们要怎么做呢?满足两个条件的状态通道才可被视为开启:

  • 相关资金已被锁定,以及
  • 双方都签署了开启状态通道的承诺(commitment)

你可以将这二者结合起来吗?当然可以!你可以通过一笔交易将二者都实现吗?这也没问题!但是,为了简化代码,我们使用 openChannel() 函数来处理第二点,并假定第一点已经实现了,为资金锁定实现带来更多的可能性——这就是这个函数 internal 的原因。下文将讲述如何为状态通道提供资金的实现。

StateChannel.sol 中,我们的 openChannel() 函数如下所示:

function openStateChannel(bytes memory packedOpenChannelData) internal returns (bool)

这里要再解释一下。(被打包的)OpenChannelData 包含定义和开启状态通道所必需的一切信息,而开启通道调用的首要功能是验证这部分信息是否有意义,然后在链上存储一个开启通道的永久记录。要注意的是,双方必须预先签署过该数据。

那么,我们需要什么样的信息呢?

channelID是用来鉴别状态通道的唯一标识。状态通道合约支持多条并行通道——另一种方法是每开启一个通道都部署一个新的合约,但是部署合约所需消耗的 gas 成本很高。为了增强普适性,双方可以随机选择一个从未用过的 ID ,而不是尝试在合约上做文章。

我们需要确定状态通道合约的地址。可以参见文末的 “防止重放攻击”,了解更多详情。

我们需要确定状态通道的两个参与方;这里一个很有趣的点是——我们实际上为每个参与者都保留了三个地址。第一个是在以太坊上的主账户——我们假定该参与者安全地保管好了该地址的私钥。第二个是一个临时的签名密钥——一旦通过客户端开启通道,就会即刻生成该密钥,有了这个密钥,就不需要在每次签署状态转换时都让 Metamask 跳出弹框来了(译者注:指使用主密钥来签名)。最后一个是状态通道关闭之时用来接收资金的地址。请注意,通过这些代码,我们可以使用临时签名密钥,或参与者的主账户来进行签名,作为临时数据丢失的后备方案。

我们需要知道每个参与者为这个通道投入的资金价值。

我们也将时间戳引入了通道。虽然这看起来不是很通用,但非常实用!状态通道需要参与者在通道开启期间保持活跃状态(在线并能够响应)。如果是在一方签署了开启通道所必需的一切数据,而另一方可以获取这些数据的情况下,二者可能要等待很长一段时间(长达数周或数月)之后,才会使用该数据来开启通道。那个时候,另一方很可能不会注意到,就会坏事。因此,我们要为这些请求设定一个有效期限。

其次,我们还要加入通道中所用状态机的地址。我们已经有意识地将代码提前部署上链。这么做主要有两个好处——只需部署一次状态机代码,而且在开启通道前参与者(或参与者信任的第三方)就可以独立验证代码。这也使得状态推进的过程变得非常直观。

最后,我们需要经过签名的状态通道初始状态哈希值。为什么呢?我们来看一下什么是状态。

状态(第一部分)

在本文开头,我提到过状态通道是使用状态机来推进通道状态的。比较理想的情况是,状态通道既不依赖,也不需要了解该状态的详细内容,因此可以将不同的状态机添加到系统中来。除此之外,状态机本身不需要了解关于状态通道的任何信息,甚至不需要知道是状态通道在调用它们。

那就让我们开始像剥洋葱那样对状态进行层层剖析吧。状态通道会存储下列运行状态:

我们使用通道 ID 和地址来识别身份并防止重放攻击,然后还有 nonce 、余额和打包好的状态通道状态。

在 Solidity 0.6 中,ABIEncoderV2 终于不再是实验性质的了——对我们帮助很大。从根本上上来说,这意味着我们可以将不理解的数据传递给能够理解它的程序进行解码了。

状态机可能需要追踪杂乱无章的数据,如同棋盘上密布的棋子,或是牌桌上散乱的扑克牌,这些都组成了完整的已签名状态。像这样对它进行编码就意味着,外层——状态通道——可以验证这些签名(状态机做不到这点)和 nonce 数列等,并在需要之时将打包好的状态传递给状态机。

生成新 nonce 也是需要在状态机之外完成的,这对于争议解决(Dispute Resolution)来说非常关键——见下文。

最后,参与者的余额也存储在状态中。我们已经花了一些时间来讨论是否能将余额存储在状态机内,并按需返回,不过我认为存储在状态中会更清楚。

开启通道(第二部分)

开启通道所需的信息中还包含两个参与方对初始状态的签名。如果要关闭通道或对通道提出争议,你需要先从参与双方共同签署的状态开始。有了这个信息之后,就可以确保这个状态是存在的,而且是已经签过名的。我们怎么生成初始状态?

生成初始状态

事实上,生成初始状态非常简单。我们提供一个可以接收 OpenChannelData 并返回初始状态的函数。该函数:

  • 会根据 OpenChannelData 中提供的数据来设置通道 ID 和初始余额
  • 会设置 address(this) 的通道地址
  • 会将 nonce 设置成零
  • 调用状态机去获取由 ABI Encoder 打包的初始状态

你会注意到,OpenChannelData 中也会包含上述信息,并将它们传递给状态机——这些都被状态机用作初始化数据,用来生成初始状态。我们后面详细讲解状态机之时,会再进行深入的介绍。

该函数是公开且没有副作用的。客户端会从链下调用代码,来生成初始状态,并对初始状态进行签名。

开启通道(第三部分)

最后,我们会对签名进行验证——这一步是在链上完成的,先是生成初始状态,对其进行哈希计算,然后将得到的哈希值与 OpenChannelData 中提供的哈希值进行比较,并验证对该哈希值的签名。

请注意,该哈希值并不一定需要传入,而是可以通过推断得出。但是,鉴于目前还处于参考实现阶段,(使用传入模式的话)代码看起来会更加清晰,而且可读性更强!

最后,我们已经验证了所有需要验证的数据,可以开启状态通道了。这一步非常简单,就是写一个标记添加到合约存储内,表明 ID 号为 #x 的通道已经开启。

不过,我们还做了其他一些事情。在这个参考实现中,我们在链上存储了该状态通道的总余额。这完全是出于安全性考虑——这很显然——不过该代码旨在用于多条并行通道。而且,令我非常顾忌的一点是,资金会不会从一条通道泄漏到另一条通道上。存储总余额可以有效预防这种情况发生。

我们通过触发事件来让客户端知道发生了什么。每进行一笔链上交易都会触发事件。

最后,我们会存储 OpenChannelData 的哈希值。这是一个很有趣的优化。在早期开发阶段,我们会将整个 OpenChannelData 数据都存储在链上——我们需要几乎一切数据(除了时间戳和状态机初始化数据)。然而, SSTORE 成本很高,结构又很庞大。最终发现,可以只存储 OpenChannelData 的哈希值,既可以节约成本,又可以降低复杂程度。对于后续需要(几乎全部)数据的合约调用来说,完整的 OpenChannelData 会作为一个参数传入该调用。然后,我们所要做的就是核实该数据的哈希值是否与已存储的哈希值相匹配,确保可以一切照常。你会在每一个外部可调用函数的开头看到执行该操作的代码。

我们终于开启了一条通道!

(未完)

FunFair:状态通道合约的参考实现,Part-2
FunFair:状态通道合约的参考实现,Part-3

原文链接: https://funfair.io/a-reference-implementation-of-state-channel-contracts/
作者: Funfair
翻译&校对: 闵敏 & 阿剑

你可能还会喜欢:

引介 | Layer 2 方案概览:从状态通道到 Roll Up
科普 | 菜鸟学习状态通道,Part-1:支付通道
干货 | 理解以太坊的第 2 层扩展方案

评论