引介

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

曾汨   |     |   339 次阅读

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


其他解决方案

不过,这不是解决争议的唯一方案。以下三点会导致通道参与者遭到惩罚(参见文末的注释)。

超时

如果对手方没有在合理的时间范围内对争议作出响应,发起方(实际上可以是任何人)可以在状态通道上宣告超时。参与者必须要能够在自己的状态通道上做出响应——我们称之为 “保持在线”。该调用本身是非常简单的:

1

这里,我们只需验证响应时间是否过长 (参见文末注释),并根据发起争议时所用状态的余额来关闭这条通道,然后惩罚未响应的对手方,并将通道关闭。我们确实需要输入状态本身,因为链上只存储了该状态的哈希,而且还是未经验证过的哈希。

使用后续状态发起挑战

状态通道上有一个概念叫作即时确定性——指的是一旦某个状态经过了参与双方的签署,就无法再更改了——无需像在传统区块链上那样等待区块得到确认。

这就意味着回滚到某个状态是违反协议的。假设你处于状态 #10 ,然后对状态 #5 发起争议(可能你处于一个更好的位置), 该操作是不被允许的,而且一下子就会被验证出来。

争议发起方的对手方只需调用

2

输入任意一个经过双方联合签署的后续状态,就可以证明争议发起方已违反协议。我们只需验证签名的有效性,核实该状态在这条通道中是有效的,以及该状态的 nonce 大于用来发起争议的状态的 nonce 即可。

如果争议发起方真的违反了协议,我们可以关闭通道,并予以惩罚。要注意的是,这可能对争议发起方尤为不利,因为其对手方可能会选择任意一个由双方联合签署的后续状态来发起挑战——甚至可以在惩罚措施还没有施行之前,找到一个自己处于最佳位置的后续状态。因此,不要对任何一个旧状态提出争议。

使用不同的 action 发起挑战

最后这个有点微妙。如果我签署了一个状态转换,并将它连同 Action A 发送给你,然后再使用另一个状态相同的 Action B 在链上发起争议,那么问题就来了。

因为你很可能已经进行了下一步操作,但是我还没将我的签名发送给你,因此你不能以之作为后续争议的解决基础。这就意味着,我可以看到你接下来的 action ,然后利用链上争议来改变我的操作。

这与随机数发生器的运作方式关联很大(参见下文),而且能够让参与者预先知道随机数发生器的结果。

因此,我们认为签署同一个状态下的多个 action 是违反协议的。如果对手方看到了争议,并意识到有另一个被签署的 action ,就可以调用

3

这里,除了常规检查之外,我们还会检查该 action 的哈希是否与我们用来发起争议的 action 的哈希不同。我们还需要核实 Action 是有效的(例如,轮到他们进行操作了),而且会实现一个有效的状态转换(例如,资金仍有结余)——我们通过运行状态机来推进状态,看是否会出现报错。我们之前已经有了一个单独的验证 Action 函数,不过这是它剩下的唯一一个用例,因此只测试推进状态会更加清楚。

如果这些测试都通过了,我们会关闭通道并惩罚争议发起方。

极端案例

最后,我们需要处理几个极端案例。

无 Action 争议

比起极端案例,这更像是一种优化。如果我已经发起了一个 action ,而你又将你对新状态的签名发送了给我,但是没有再发起一个 action ,我可以用你对该状态的签名发起一个争议,而不一定要在链上实现整个状态转换。

但是,要注意的是,如果你以这种方式发起了一个争议,然后下一个可以发起 action 的参与方只能是你的话,那么麻烦就来了,因为你不能解决自己的争议。

同意并关闭通道最后这一点是必要的。鉴于我们对可终止性(finalisability)的定义,你不能强迫参与者在一个可终止的状态下继续发起 action 。这就好比是在玩家想要停止并关闭通道之时,强迫他们重新再玩一局。

因此,我们有了另一种 resolveDisputeWithAction() ——如果该争议采取的状态具有可终止性,对手方可以直接同意该状态通道,并要求以可确定状态关闭这条通道。争议发起者已经签署了可终止状态,不需要再签署关闭通道请求。对手方要调用:

4

输入一个已签署过的 “End Channel” 类 action (这里用现成的 action 范例会比较清楚)和状态。这两个输入都要经受常规检查,如果状态确实具有可终止性的话,状态通道就可以在不做出惩罚的情况下正常关闭。

处理资金

为状态通道提供资金

如上文所述,openChannel() 方法是针对内部的,并且认为资金已锁定在合约内。这可以通过很多方法来实现。由于我们的用例是通过 ERC20 代币来实现的,我们现有的实现就利用了这一点。这就意味着我们可以在代币合约中新增多签名功能——我们也已经这么做了。在 FUNTokenController.sol 中,你会看到 multiSigTokenTransferAndContractCall() 函数。这个函数可以让参与双方联合签署信息,从各自的账户中将代币转移到第三方账户中,然后对地址上的合约调用 afterMultiSigTransfer() 方法。

然后,我们从 DisputableStateChannel 中衍生出了一个新的类型,有一个很抓人眼球的名称叫作 TokenMultiSigStateChannel ,使用的是 afterMultiSigTransfer() 方法。该代码假定代币转移是都是通过该方法调用实现的,因此该代码是被严格锁定的,只有白名单内的地址才能调用它。

最后是代码中最复杂的一部分。代币合约中的多签名转移方法需要自己的一套数据,以及开启通道所需的数据包。我们需要手动验证数据的一致性——如果参与者的余额与代币合约所转移的余额不同,我们就无法开启状态通道。虽然有点丑,但是将代码拆成这样就代表只有这个合约需要了解执行多签转账所需的数据结构。

一旦经过验证之后,该函数就可以在其父类上调用 openChannel() 方法,为状态通道注入资金,令其成功开启。

为状态通道提供资金的方法有很多,这不是其中最简单或是最常用的一种。但它有一个很明显的优点,就是可以通过一个交易来开启通道。我们依旧相信,为用户节省成本和时间是一件好事。

将资金从状态通道中释放出来

成功调用 closeChannel(),或是通过解决争议强制关闭通道之后,我们需要将资金归还给参与者。

基本的 State Channel 类确实知道每个参与方的余额都在发生变化,但是并不知道这些资金的具体情况(代币、以太币等等)。它不一定要知道正在变化中的余额是在进行正确的资金分配。

从中衍生而来的 Disputable State Channel 类可以分辨是否需要惩罚某位参与者,但是不一定要知道具体的惩罚措施是什么。

因此,我们将这个逻辑放到代币多签名状态通道合约中来看。合约知道在这个情况下,资金就是代币,也知道代币合约的地址。然后,我们需要做出决定。我们(FunFair)对如何行使惩罚措施有具体的规则。此外,我们还有一个规定,就是要拿出一些资金奖励给作出贡献的第三方(游戏开发者、成员组织等等)。这种逻辑似乎比较适合放在状态机中,这部分代码对我们来说是最特殊的。

我们定义了一个 distributeFunds() 方法,其原型如下:

5

位于基础的状态通道合约内,但是并未执行。我们会在代 Multi-Sig State Channel 合约内执行该代码,执行过程如下:

  • 在状态机上调用 getPayouts() ,输入状态通道的余额、状态机的最终状态,以及是否需要对任何人进行惩罚
  • 接收付款金额和地址的数组
  • 验证这些付款地址的余额总和是否等于整条通道的余额
  • 付款

这里的实现依然需要一些思考。状态通道本身不知道参与者的付款地址,而且有时可能需要用到其他数据——以我们的实现为例,罚款金额是根据通道开启时的初始金额来计算的,因此还需要输入另外的数据才能进行处理。我相信之后这一块的设计会变得更加清晰。

FunFair 的状态机——命运机

过去两年间,我们一直都以 “命运通道” 这个名称来指称整段代码,本次重构会将整个随机数生成器融入到状态机之中,使状态通道变得更加整洁,在运行过程中更加独立于状态机。

FateMachine.sol 在我们的系统中是状态机的完整实现——通过提供一个整洁的游戏规则界面,我们还展示了一种可以让该代码支持多种游戏的方法。该命运机执行的是一类基于随机数生成器的游戏,让玩家在预先制定好的规则下进行游戏。轮盘赌、21 点、百家乐等赌场游戏都属于这一类,包括大多数老虎机在内。

对于传统区块链来说,实现随机性是出了名的难——每个交易本身都具有确定性,因为它需要由成千上万的节点计算得出并予以验证。我们的方法采用了提交-显示(commit-reveal)机制:

以掷骰子为例,假设我们需要 1 到 6 之间的一个数。这个随机数不能由某个玩家来选择,因为 ta 可以任意选择自己想要的那个数。如果是让两位玩家各自选择一个随机数,然后把它们加起来(例如,每位玩家选择一个 1 到 6 之间的数,然后把这两个数相加,取其总和对 6 进行取模),那么情况就会好一点。但是,两位玩家需要交换各自的数字,以便对最终结果达成共识。

如果有一个人先透露了自己的数字——另一个人可能会改变自己所选的数字来影响最终结果。

提交-显示机制可以有效防止这种情况发生。两位玩家各自选择一个很大的随机数,并对其进行哈希运算,然后交换各自的哈希值(不受交换顺序影响)。这个时候,如果选用的哈希算法足够好,而且原数字足够大的话(我们采用的是标准的以太坊 keccak256 哈希函数,选取的随机数长达 256 位),是无法根据已提交的哈希值倒推出原数字的。

然后两位玩家显示各自选取的随机数。双方都可验证该这两个数经过哈希之后是否与之前已提交的哈希值相符,并且通过随机数生成器生成最终结果。我们的做法是将已提交的两个随机数连起来形成一个长达 512 位的数字,对其进行哈希运算,然后根据我们的特定需求进行取模运算。这一过程是由状态机和状态通道的规则约束的,因此也是协议的一部分——玩家必须轮流公开各自的随机数,而且必须确保计算出正确的哈希值。如果未能满足上述要求(显示或计算环节中任意一环出了问题),则视为违反协议,就会触发上文所述的争议机制。

此外,还要确保整个游戏符合逻辑,也就是说在通过随机数生成器生成最终结果之后,就不能再发生任何影响结果的事。如果有玩家进行了一个操作(例如,选择对骰子的某个点数下注),必须在得出结果之前完成,并且不可更改。

如上文所述,整个系统虽然有效,但是效率很低,而且繁琐。这个掷骰子的游戏可能是这样的:

  • (玩家)——我赌 6 点,押 100 个代币
  • (庄家)——好的!(庄家不需要下注)
  • (玩家)——我的提交值是 “0x......”
  • (庄家)——我的提交值是 “0x......”
  • (玩家)——我的显示值是 “0x......”
  • (庄家)——我的显示值是 “0x......”

光是掷个骰子就要 6 条信息——代表 6 次状态转换。

第一部分是使用一连串哈希值来实现的。每位参与者都生成一个随机数,并对其进行多次哈希运算——我们默认使用 10000 ——并将中间用到的每个哈希值保存在一个列表里。我们认为对一个 256 位的数进行 keccak256 哈希运算就能具备足够的随机性,因此,这一连串哈希值中任意两个连续哈希值都能用在 “显示-提交” 方案中:后一个用于显示,前一个用于提交;这样就可以提交-显示很多个随机数。

实际上,这就意味着当状态通道开启之时,每位参与者都将最后一个生成的哈希值用作第一个提交值。每当他们需要一个新的数字之时,只需公开列表上的前一个数字即可。对手方可以验证这个数的哈希值是否与提交值一致。我们可以重复执行该操作,直到用完所有随机数为止。现在,这个过程如下所示:

  • 玩家和庄家预先提交各自列表中最后一个哈希值
  • (玩家)——我赌 6 点,押 100 个代币
  • (庄家)——好的
  • (玩家)——我的显示值是“0x......”
  • (庄家)——我的显示值是“0x......”

只需要 4 步就可以一次搞定——这样是不是好多了。如果我们没有给庄家拒绝下注的权利,那就只需要两步,因为玩家可以直接说 “我赌 6 点,押 100 个代币,我的显示值是‘0x......’”。这个时候,由于庄家知道自己的随机数,就能确定这场赌局的结果。因此,在这种情况下,协议必须强制要求庄家下注。

有的游戏会分好几个阶段,例如 21 点。在这种情况下,虽然庄家可以拒绝开始游戏,但是一旦游戏开始之后,就不能退出了——因此,每新发一张牌,我们都要完成上文所述的两个步骤——例如,“我要拿牌,我的显示值是 ‘0x......’”。我们把每一局未分胜负的游戏称为一轮。

经过最终的优化之后,请求和同意下注这两个操作完全从状态机中独立了出来。我们的设计是,玩家提出请求(例如,赌 6 点,押 100 代币),如果庄家同意的话,就会签署这个请求,并将签名发回给玩家。状态转换逻辑则如下所示:

  • (玩家)我赌 6 点,押 100 代币,这是庄家的签名,表示他们愿意接受参与游戏,我的显示值是 “0x……”
  • (庄家)我的显示值是 “0x……”

最后,我们将整个随机数生成器和游戏设置控制在了两步之内,我们认为这对于 提交-显示 机制来说已经是最佳方案了。

状态转换和游戏规则

如果要在提交-显示机制的基础上实现游戏,我们需要通过一个系统来定义游戏规则。以掷骰子游戏为例,玩家从 1 到 6 之间选择一个数字,如果猜对了,就可以赢得 6 倍的赌注,如果没猜对,就会输掉赌注——我们可以写一个简单的函数:

6

这看起来确实可以解决问题——在这段代码中,我们显然没有检查溢出情况,或是所猜数字是否真的在 0 到 5 之间。如果是后一种情况,你在 7 上下注是绝对赢不了的。但实际情况并非总是如此,我们需要采取另外的措施来避免这些情况,具体会在下文讲到。

你应该已经预料到了,我们的状态机的状态可能如下所示:

7

为了玩游戏,我们需要完成两次状态推进。第一次转换是由玩家下注并公开他的那一半随机数。第二次转换是由庄家公开另一半随机数,然后游戏规则被调用,状态(以及参与者金额)得到更新。

状态机需要追踪游戏的进度,并且缓存玩家的操作和随机数生成器种子,以便将二者与庄家的随机数生成器种子相结合。在庄家显示了随机数生成器种子之后,会触发游戏规则的调用。这部分代码位于 advanceState() 内。

不过,我们还需要验证每位参与者的 action ,这就稍微有点复杂了。针对玩家的操作,我们需要验证:

  • 是否轮到该玩家发起操作
  • 他们所显示的随机数生成器种子经过哈希运算后是否与列表中的上一个哈希值一致
  • 如果是一轮游戏中发起的第一个 action ,上面是否有跟注的庄家的有效签名
  • 赌注是否违反游戏规则
  • 玩家是否有足够的资金下注
  • 庄家是否有足够的资金跟注

最后三点非常重要。让我们逐条分析一下:

玩家所进行的每个 action 都需要经过验证。这些 action 都是显而易见的——例如,在 21 点里,只有某几手可以选择加倍(double)或分牌(split)。但是,等庄家显示了自己的哈希值之后再验证就晚了,因为状态的推进先于了玩家发起的 action 。我们更喜欢利用协议规则来避免无效赌注,否则就要编写大量代码来处理。因此,玩家通过调用游戏规则中的第二个功能来发起 action 之时,即表明赌注有效,如果状态转换失败,则表明赌注无效。

说句很重要的题外话,游戏规则和状态转换逻辑都不能在遇到有效 action 时抛出异常,否则状态通道就会停止,无辜的参与方会在争议过程中遭受惩罚。

我们需要验证玩家有足够的资金来下注。这就有点复杂了,因为像是 21 点这种多阶段的游戏,可能会在游戏中途需要更多资金。根据我们的状态通道实现,一旦通道开启,就不能再往里注入资金。因此,我们会验证玩家有足够的资金来支付一轮游戏所需投入的最高金额。经过优化之后,游戏规则会对赌注进行验证,然后再将这部分金额返还。

我们还需要验证庄家是否有足够的钱付给获胜的玩家。这里不仅要考虑玩家可能会赢到的最高金额,也要算上玩家在游戏过程中所有可能进行的操作(如 21 点中的加倍和分牌)所需的金额。重要的是,游戏规则会准确计算出这两部分金额。

对庄家 action 的验证要简单得多。庄家只会显示自己那一半的随机数生成器,因此我们只需要验证这个随机数生成器的哈希值是否正确即可。

一旦参与双方都发起 action 之后,状态机就会调用游戏规则合约,来确定这场赌局的结果,并适时更新状态,将参与者的余额变化情况传回状态通道。

确定最终结果

如上文所讨论的,我们依然在努力寻找一种最佳的解决方案。尽管游戏规则合约返回了一个 isEndOfRound 标记(即 isFinalisable(可终止)),以便确定这条状态通道是否可以彻底终止。

例如,getPayouts() 的实现就是其中一种方法。我们展示了两种非对称惩罚函数法,并演示了如何向第三方支付佣金。这个代码实际上会复杂得多。为了保持简单化,我这里没有将完整的实现写出来。你会注意到,命运机状态中还嵌有一个 AdditionalData 结构,这是我还没有提到的。在我们实际的代码中, AdditionalData 里都是状态通道开启过程中积累下来的数据,以便在通道关闭之时进行更复杂的计算(例如,能够使用总的下注金额或下注次数进行计算)。这部分代码针对性很强,因此我不在此公布。

游戏规则合约

最后一步是定义游戏规则合约。我们已经尽力降低了编写这类合约的难度——因为这类合约的数量可能会达到成千上万个,每个都针对不同的游戏。

命运机在其初始化数据中输入了游戏合约地址——该地址就存储在状态内。就目前而言,我们不支持在状态通道内更改游戏,不过可以采用另一种相对简单的方法,就是在玩家和庄家均同意的情况下,编写一些新的 action 来更换新的游戏合约,而且会更新到状态中。

你会注意到,游戏规则合约有自己的状态——这个状态内还嵌有一组被压缩的二进制数据,形成了一个类似洋葱的结构。这个状态内可以存储专属的状态数据——例如,在 21 点游戏的中轮所发的牌等等。我们还用它来存储赌局的最终结果。要知道轮盘赌的输赢似乎不难,如果没有多次押注的话,要知道输赢情况是很容易的。此外,知道球最终落在哪里很重要,因此可以把它显示给玩家!

游戏规则合约的实现还规定了需要处理的初始化数据。实际上,在这方面我还没有发现一个现实用例,不过我觉得是有的。

游戏规则合约示例

在本文中,我已经写到了一些游戏规则合约的示例,来表明它们可以有多简单,以及可以通过何种方式来实现。它们分别是,让玩家来赌正反面的抛硬币游戏,每一轮都有很多操作的高低牌游戏(hi-lo),最后是欧洲轮盘赌的完整实现——其中还展示了将数据存储在代码而非存储空间内的技术。

Jeremy Longley 和 FunFair 团队写于 2019 年 8 月。

(未完)


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


你可能还会喜欢:

在 Layer-2 和 Layer-1 上异曲同工的技术
Layer-2 中的有效性证明与错误性证明
菜鸟学习状态通道,Part-3:多跳交易/中心辐射通道

 
0 人喜欢