使用零知识实现秘密状态
区块链上没有秘密。发布在区块链上的所有内容都向所有人公开,任何人都可以读取。这是必要的,因为区块链的基础是任何人都可以对其进行验证。然而,游戏通常依赖于秘密状态。例如,如果你可以直接在区块浏览器上查看地图,那么扫雷 (在新标签页中打开)游戏就完全失去了意义。
最简单的解决方案是使用服务器组件来保存秘密状态。然而,我们使用区块链的原因是为了防止游戏开发者作弊。我们需要确保服务器组件的诚实性。服务器可以提供状态的哈希,并使用零知识证明来证明用于计算移动结果的状态是正确的。
阅读本文后,你将了解如何创建这种保存秘密状态的服务器、用于显示状态的客户端,以及用于两者之间通信的链上组件。我们将使用的主要工具包括:
| 工具 | 用途 | 验证版本 |
|---|---|---|
| Zokrates (在新标签页中打开) | 零知识证明及其验证 | 1.1.9 |
| TypeScript (在新标签页中打开) | 用于服务器和客户端的编程语言 | 5.4.2 |
| Node (在新标签页中打开) | 运行服务器 | 20.18.2 |
| Viem (在新标签页中打开) | 与区块链通信 | 2.9.20 |
| MUD (在新标签页中打开) | 链上数据管理 | 2.0.12 |
| React (在新标签页中打开) | 客户端用户界面 | 18.2.0 |
| Vite (在新标签页中打开) | 提供客户端代码服务 | 4.2.1 |
扫雷游戏示例
扫雷 (在新标签页中打开) 是一款包含隐藏雷区地图的游戏。玩家选择在特定位置挖掘。如果该位置有地雷,游戏结束。否则,玩家将获得该位置周围八个方块中的地雷数量。
此应用程序使用 MUD (在新标签页中打开) 编写,该框架允许我们使用键值数据库 (在新标签页中打开)在链上存储数据,并自动将该数据与链下组件同步。除了同步之外,MUD 还可以轻松提供访问控制,并让其他用户能够无许可地扩展 (在新标签页中打开)我们的应用程序。
运行扫雷游戏示例
要运行扫雷游戏示例:
-
确保你已安装必备组件 (在新标签页中打开):Node (在新标签页中打开)、Foundry (在新标签页中打开)、
git(在新标签页中打开)、pnpm(在新标签页中打开) 和mprocs(在新标签页中打开)。 -
克隆代码库。
git clone https://github.com/qbzzt/20240901-secret-state.git -
安装包。
cd 20240901-secret-state/ pnpm install npm install -g mprocs如果 Foundry 是作为
pnpm install的一部分安装的,你需要重新启动命令行 shell。 -
编译合约
cd packages/contracts forge build cd ../.. -
启动程序(包括一条 anvil (在新标签页中打开) 区块链)并等待。
mprocs请注意,启动需要很长时间。要查看进度,首先使用向下箭头滚动到 contracts 选项卡,以查看正在部署的 MUD 合约。当你收到消息 Waiting for file changes… 时,说明合约已部署,进一步的进度将在 server 选项卡中显示。在那里,你需要等待直到收到消息 Verifier address: 0x....。
如果此步骤成功,你将看到
mprocs屏幕,左侧是不同的进程,右侧是当前所选进程的控制台输出。如果
mprocs出现问题,你可以手动运行这四个进程,每个进程在各自的命令行窗口中运行:-
Anvil
cd packages/contracts anvil --base-fee 0 --block-time 2 -
Contracts
cd packages/contracts pnpm mud dev-contracts --rpc http://127.0.0.1:8545 -
Server
cd packages/server pnpm start -
Client
cd packages/client pnpm run dev
-
-
现在你可以浏览到客户端 (在新标签页中打开),点击 New Game(新游戏),然后开始游玩。
表
我们需要在链上建立几个表 (在新标签页中打开)。
-
Configuration:此表是一个单例,它没有密钥,只有一条记录。它用于保存游戏配置信息:height:雷区的高度width:雷区的宽度numberOfBombs:每个雷区中的炸弹数量
-
VerifierAddress:此表也是一个单例。它用于保存配置的一部分,即验证者合约的地址 (verifier)。我们本可以将此信息放在Configuration表中,但它是由另一个组件(服务器)设置的,因此将其放在单独的表中更容易。 -
PlayerGame:密钥是玩家的地址。数据为:gameId:32 字节的值,即玩家正在游玩的地图的哈希(游戏标识符)。win:一个布尔值,表示玩家是否赢得了游戏。lose:一个布尔值,表示玩家是否输掉了游戏。digNumber:游戏中成功挖掘的次数。
-
GamePlayer:此表保存反向映射,从gameId到玩家地址。 -
Map:密钥是包含三个值的元组:gameId:32 字节的值,即玩家正在游玩的地图的哈希(游戏标识符)。x坐标y坐标
该值是一个数字。如果检测到炸弹,则为 255。否则,它是该位置周围的炸弹数量加一。我们不能仅仅使用炸弹的数量,因为默认情况下 EVM 中的所有存储和 MUD 中的所有行值都为零。我们需要区分“玩家尚未在此处挖掘”和“玩家在此处挖掘,发现周围有零个炸弹”。
此外,客户端和服务器之间的通信通过链上组件进行。这也是使用表来实现的。
PendingGame:未处理的开始新游戏的请求。PendingDig:未处理的在特定游戏中特定位置挖掘的请求。这是一个链下表 (在新标签页中打开),这意味着它不会被写入 EVM 存储,只能使用事件在链下读取。
执行和数据流
这些流程协调客户端、链上组件和服务器之间的执行。
初始化
当你运行 mprocs 时,会发生以下步骤:
-
mprocs(在新标签页中打开) 运行四个组件:- Anvil (在新标签页中打开),运行本地区块链
- Contracts (在新标签页中打开),编译(如果需要)并部署 MUD 的合约
- Client (在新标签页中打开),运行 Vite (在新标签页中打开) 以向 Web 浏览器提供 UI 和客户端代码。
- Server (在新标签页中打开),执行服务器操作
-
contracts包部署 MUD 合约,然后运行PostDeploy.s.sol脚本 (在新标签页中打开)。此脚本设置配置。来自 GitHub 的代码指定了一个包含八个地雷的 10x5 雷区 (在新标签页中打开)。 -
服务器 (在新标签页中打开)首先设置 MUD (在新标签页中打开)。除其他事项外,这会激活数据同步,以便在服务器内存中存在相关表的副本。
-
服务器订阅一个函数,以便在
Configuration表更改时 (在新标签页中打开)执行。此函数 (在新标签页中打开)在PostDeploy.s.sol执行并修改表后被调用。 -
当服务器初始化函数获得配置后,它会调用
zkFunctions(在新标签页中打开) 来初始化服务器的零知识部分。这必须在我们获得配置之后才能发生,因为零知识函数必须将雷区的宽度和高度作为常量。 -
在服务器的零知识部分初始化之后,下一步是将零知识验证合约部署到区块链 (在新标签页中打开)并在 MUD 中设置验证者地址。
-
最后,我们订阅更新,以便在玩家请求开始新游戏 (在新标签页中打开)或在现有游戏中挖掘 (在新标签页中打开)时能够看到。
新游戏
当玩家请求新游戏时,会发生以下情况。
-
如果该玩家没有正在进行的游戏,或者有一个游戏但 gameId 为零,客户端将显示一个新游戏按钮 (在新标签页中打开)。当用户按下此按钮时,React 会运行
newGame函数 (在新标签页中打开)。 -
newGame(在新标签页中打开) 是一个System调用。在 MUD 中,所有调用都通过World合约路由,在大多数情况下,你调用<namespace>__<function name>。在这种情况下,调用的是app__newGame,然后 MUD 将其路由到GameSystem中的newGame(在新标签页中打开)。 -
链上函数检查玩家是否没有正在进行的游戏,如果没有,则将请求添加到
PendingGame表中 (在新标签页中打开)。 -
服务器检测到
PendingGame中的更改并运行订阅的函数 (在新标签页中打开)。此函数调用newGame(在新标签页中打开),后者又调用createGame(在新标签页中打开)。 -
createGame做的第一件事是创建一个包含适当数量地雷的随机地图 (在新标签页中打开)。然后,它调用makeMapBorders(在新标签页中打开) 创建一个带有空白边界的地图,这对于 Zokrates 是必需的。最后,createGame调用calculateMapHash,以获取地图的哈希,该哈希用作游戏 ID。 -
newGame函数将新游戏添加到gamesInProgress中。 -
服务器做的最后一件事是调用
app__newGameResponse(在新标签页中打开),这是在链上的。此函数位于不同的System(即ServerSystem(在新标签页中打开))中,以启用访问控制。访问控制在 MUD 配置文件 (在新标签页中打开)mud.config.ts(在新标签页中打开) 中定义。访问列表仅允许单个地址调用
System。这限制了对服务器函数的访问,使其仅限于单个地址,因此没有人可以冒充服务器。 -
链上组件更新相关表:
- 在
PlayerGame中创建游戏。 - 在
GamePlayer中设置反向映射。 - 从
PendingGame中删除请求。
- 在
-
服务器识别出
PendingGame中的更改,但不执行任何操作,因为wantsGame(在新标签页中打开) 为 false。 -
在客户端上,
gameRecord(在新标签页中打开) 设置为玩家地址的PlayerGame条目。当PlayerGame更改时,gameRecord也会更改。 -
如果
gameRecord中有值,并且游戏尚未分出胜负,客户端将显示地图 (在新标签页中打开)。
挖掘
-
玩家点击地图单元格的按钮 (在新标签页中打开),这会调用
dig函数 (在新标签页中打开)。此函数在链上调用dig(在新标签页中打开)。 -
链上组件执行一些健全性检查 (在新标签页中打开),如果成功,则将挖掘请求添加到
PendingDig(在新标签页中打开) 中。 -
服务器检测到
PendingDig中的更改 (在新标签页中打开)。如果有效 (在新标签页中打开),它会调用零知识代码 (在新标签页中打开)(如下所述)以生成结果及其有效的证明。 -
digResponse做两件事。首先,它检查零知识证明 (在新标签页中打开)。然后,如果证明通过,它会调用processDigResult(在新标签页中打开) 来实际处理结果。 -
processDigResult检查游戏是否已失败 (在新标签页中打开)或获胜 (在新标签页中打开),并更新链上地图Map(在新标签页中打开)。 -
客户端自动获取更新并更新显示给玩家的地图 (在新标签页中打开),并在适用的情况下告诉玩家是赢还是输。
使用 Zokrates
在上面解释的流程中,我们跳过了零知识部分,将其视为一个黑盒。现在让我们打开它,看看这些代码是如何编写的。
对地图进行哈希处理
我们可以使用这段 JavaScript 代码 (在新标签页中打开)来实现 Poseidon (在新标签页中打开),即我们使用的 Zokrates 哈希函数。然而,虽然这样做会更快,但也比直接使用 Zokrates 哈希函数更复杂。这是一个教程,因此代码针对简单性而非性能进行了优化。因此,我们需要两个不同的 Zokrates 程序,一个仅用于计算地图的哈希(hash),另一个用于实际创建地图上某个位置挖掘结果的零知识证明(dig)。
哈希函数
这是计算地图哈希的函数。我们将逐行查看这段代码。
import "hashes/poseidon/poseidon.zok" as poseidon;
import "utils/pack/bool/pack128.zok" as pack128;
这两行从 Zokrates 标准库 (在新标签页中打开)导入了两个函数。第一个函数 (在新标签页中打开)是 Poseidon 哈希 (在新标签页中打开)。它接收一个 field 元素 (在新标签页中打开)数组并返回一个 field。
Zokrates 中的 field 元素通常小于 256 位,但相差不大。为了简化代码,我们将地图限制为最多 512 位,并对包含四个 field 的数组进行哈希处理,在每个 field 中我们仅使用 128 位。pack128 函数 (在新标签页中打开)为此目的将 128 位数组转换为 field。
def hashMap(bool[${width+2}][${height+2}] map) -> field {
这一行开始定义一个函数。hashMap 接收一个名为 map 的参数,这是一个二维 bool(ean) 数组。地图的大小为 width+2 乘 height+2,原因将在下文解释。
我们可以使用 ${width+2} 和 ${height+2},因为 Zokrates 程序在此应用程序中作为模板字符串 (在新标签页中打开)存储。${ 和 } 之间的代码由 JavaScript 计算,这样该程序就可以用于不同的地图大小。地图参数周围有一个宽度为 1 个位置的无炸弹边界,这就是我们需要在宽度和高度上加 2 的原因。
返回值是一个包含哈希的 field。
bool[512] mut map1d = [false; 512];
地图是二维的。然而,pack128 函数不适用于二维数组。因此,我们首先使用 map1d 将地图展平为 512 字节的数组。默认情况下,Zokrates 变量是常量,但我们需要在循环中为该数组赋值,因此我们将其定义为 mut (在新标签页中打开)。
我们需要初始化该数组,因为 Zokrates 没有 undefined。[false; 512] 表达式表示一个包含 512 个 false 值的数组 (在新标签页中打开)。
u32 mut counter = 0;
我们还需要一个计数器来区分我们在 map1d 中已经填充的位和尚未填充的位。
for u32 x in 0..${width+2} {
这就是在 Zokrates 中声明 for 循环 (在新标签页中打开)的方法。Zokrates 的 for 循环必须具有固定的边界,因为虽然它看起来像一个循环,但编译器实际上会将其“展开”。表达式 ${width+2} 是一个编译时常量,因为 width 是由 TypeScript 代码在调用编译器之前设置的。
for u32 y in 0..${height+2} {
map1d[counter] = map[x][y];
counter = counter+1;
}
}
对于地图中的每个位置,将该值放入 map1d 数组中并递增计数器。
field[4] hashMe = [
pack128(map1d[0..128]),
pack128(map1d[128..256]),
pack128(map1d[256..384]),
pack128(map1d[384..512])
];
使用 pack128 从 map1d 创建一个包含四个 field 值的数组。在 Zokrates 中,array[a..b] 表示从 a 开始到 b-1 结束的数组切片。
return poseidon(hashMe);
}
使用 poseidon 将此数组转换为哈希。
哈希程序
服务器需要直接调用 hashMap 来创建游戏标识符。然而,Zokrates 只能调用程序上的 main 函数来启动,因此我们创建了一个带有调用哈希函数的 main 的程序。
${hashFragment}
def main(bool[${width+2}][${height+2}] map) -> field {
return hashMap(map);
}
挖掘程序
这是应用程序零知识部分的核心,我们在这里生成用于验证挖掘结果的证明。
${hashFragment}
// 位置 (x,y) 的地雷数量
def map2mineCount(bool[${width+2}][${height+2}] map, u32 x, u32 y) -> u8 {
return if map[x+1][y+1] { 1 } else { 0 };
}
为什么需要地图边界
零知识证明使用算术电路 (在新标签页中打开),它没有与 if 语句简单等效的结构。相反,它们使用等效的条件运算符 (在新标签页中打开)。如果 a 可以是 0 或 1,你可以将 if a { b } else { c } 计算为 ab+(1-a)c。
正因为如此,Zokrates 的 if 语句总是会计算两个分支。例如,如果你有以下代码:
bool[5] arr = [false; 5];
u32 index=10;
return if index>4 { 0 } else { arr[index] }
它会报错,因为它需要计算 arr[10],即使该值稍后会乘以零。
这就是我们需要在地图周围设置一个宽度为 1 个位置的边界的原因。我们需要计算某个位置周围的地雷总数,这意味着我们需要查看我们正在挖掘的位置的上一行和下一行、左侧和右侧的位置。这意味着这些位置必须存在于提供给 Zokrates 的地图数组中。
def main(private bool[${width+2}][${height+2}] map, u32 x, u32 y) -> (field, u8) {
默认情况下,Zokrates 证明包含其输入。除非你确切知道是哪个位置,否则知道某个位置周围有五个地雷毫无用处(而且你不能仅仅将其与你的请求匹配,因为那样证明者可能会使用不同的值而不告诉你)。然而,我们需要在将地图提供给 Zokrates 的同时对其保密。解决方案是使用 private 参数,该参数_不会_被证明泄露。
这开启了另一种滥用的可能。证明者可以使用正确的坐标,但创建一个在该位置周围(甚至可能在该位置本身)有任意数量地雷的地图。为了防止这种滥用,我们让零知识证明包含地图的哈希,即游戏标识符。
return (hashMap(map),
这里的返回值是一个元组,包含地图哈希数组以及挖掘结果。
if map2mineCount(map, x, y) > 0 { 0xFF } else {
我们使用 255 作为特殊值,以防该位置本身有炸弹。
map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
}
);
}
如果玩家没有触雷,则将该位置周围区域的地雷数量相加并返回。
在 TypeScript 中使用 Zokrates
Zokrates 有一个命令行界面,但在本程序中,我们在 TypeScript 代码 (在新标签页中打开)中使用它。
包含 Zokrates 定义的库称为 zero-knowledge.ts (在新标签页中打开)。
import { initialize as zokratesInitialize } from "zokrates-js"
导入 Zokrates JavaScript 绑定 (在新标签页中打开)。我们只需要 initialize (在新标签页中打开) 函数,因为它返回一个解析为所有 Zokrates 定义的 promise。
export const zkFunctions = async (width: number, height: number) : Promise<any> => {
与 Zokrates 本身类似,我们也只导出一个函数,该函数也是异步的 (在新标签页中打开)。当它最终返回时,它会提供几个函数,我们将在下面看到。
const zokrates = await zokratesInitialize()
初始化 Zokrates,从库中获取我们需要的一切。
const hashFragment = `
import "utils/pack/bool/pack128.zok" as pack128;
import "hashes/poseidon/poseidon.zok" as poseidon;
.
.
.
}
`
const hashProgram = `
${hashFragment}
.
.
.
`
const digProgram = `
${hashFragment}
.
.
.
`
接下来是我们上面看到的哈希函数和两个 Zokrates 程序。
const digCompiled = zokrates.compile(digProgram)
const hashCompiled = zokrates.compile(hashProgram)
在这里我们编译这些程序。
// 创建用于零知识验证的密钥。
// 在生产系统中,你需要使用设置仪式。
// (https://zokrates.github.io/toolbox/trusted_setup.html#initializing-a-phase-2-ceremony)。
const keySetupResults = zokrates.setup(digCompiled.program, "")
const verifierKey = keySetupResults.vk
const proverKey = keySetupResults.pk
在生产系统上,我们可能会使用更复杂的设置仪式 (在新标签页中打开),但这对于演示来说已经足够了。用户知道证明者密钥并不是问题——除非事情是真的,否则他们仍然无法使用它来证明。因为我们指定了熵(第二个参数 ""),所以结果总是相同的。
注意: 编译 Zokrates 程序和创建密钥是缓慢的过程。没有必要每次都重复这些操作,只需在地图大小改变时进行即可。在生产系统上,你只需执行一次,然后存储输出。我在这里不这样做的唯一原因是为了简单起见。
calculateMapHash
const calculateMapHash = function (hashMe: boolean[][]): string {
return (
"0x" +
BigInt(zokrates.computeWitness(hashCompiled, [hashMe]).output.slice(1, -1))
.toString(16)
.padStart(64, "0")
)
}
computeWitness (在新标签页中打开) 函数实际运行 Zokrates 程序。它返回一个包含两个字段的结构:output(作为 JSON 字符串的程序输出)和 witness(创建结果的零知识证明所需的信息)。在这里我们只需要输出。
输出是一个形式为 "31337" 的字符串,即用引号括起来的十进制数。但我们为 viem 所需的输出是形式为 0x60A7 的十六进制数。因此,我们使用 .slice(1,-1) 去除引号,然后使用 BigInt 将剩余的字符串(即十进制数)转换为 BigInt (在新标签页中打开)。.toString(16) 将此 BigInt 转换为十六进制字符串,而 "0x"+ 添加了十六进制数的标记。
// 挖掘并返回结果的零知识证明
// (服务端代码)
零知识证明包含公共输入(x 和 y)以及结果(地图的哈希和炸弹数量)。
const zkDig = function(map: boolean[][], x: number, y: number) : any {
if (x<0 || x>=width || y<0 || y>=height)
throw new Error("Trying to dig outside the map")
在 Zokrates 中检查索引是否越界是个问题,所以我们在这里进行检查。
const runResults = zokrates.computeWitness(digCompiled, [map, `${x}`, `${y}`])
执行挖掘程序。
const proof = zokrates.generateProof(
digCompiled.program,
runResults.witness,
proverKey)
return proof
}
使用 generateProof (在新标签页中打开) 并返回证明。
const solidityVerifier = `
// Map size: ${width} x ${height}
\n${zokrates.exportSolidityVerifier(verifierKey)}
`
一个 Solidity 验证者,这是一个我们可以部署到区块链上的智能合约,用于验证由 digCompiled.program 生成的证明。
return {
zkDig,
calculateMapHash,
solidityVerifier,
}
}
最后,返回其他代码可能需要的所有内容。
安全测试
安全测试非常重要,因为功能性错误最终会暴露出来。但如果应用程序不安全,这种隐患很可能会隐藏很长时间,直到有人作弊并卷走属于他人的资源时才会被发现。
权限
在这个游戏中有一个特权实体,即服务器。它是唯一被允许调用 ServerSystem (在新标签页中打开) 中功能的用户。我们可以使用 cast (在新标签页中打开) 来验证对许可型函数的调用是否仅限于服务器账户。
服务器的私钥位于 setupNetwork.ts 中 (在新标签页中打开)。
-
在运行
anvil(区块链)的计算机上,设置这些环境变量。WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d -
使用
cast尝试将验证者地址设置为未经授权的地址。cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEY不仅
cast会报告失败,而且你可以在浏览器的游戏中打开 MUD Dev Tools,点击 Tables,然后选择 app__VerifierAddress。可以看到该地址不为零。 -
将验证者地址设置为服务器的地址。
cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $AUTHORIZED_KEYapp__VerifiedAddress 中的地址现在应该为零。
同一个 System 中的所有 MUD 函数都经过相同的访问控制,因此我认为这个测试已经足够了。如果你觉得不够,可以检查 ServerSystem (在新标签页中打开) 中的其他函数。
零知识滥用
验证 Zokrates 的数学原理超出了本教程的范围(也超出了我的能力)。但是,我们可以对零知识代码运行各种检查,以验证如果操作不正确,它就会失败。所有这些测试都需要我们更改 zero-knowledge.ts (在新标签页中打开) 并重新启动整个应用程序。仅仅重启服务器进程是不够的,因为这会使应用程序处于一种不可能的状态(玩家有一个正在进行的游戏,但该游戏对服务器不再可用)。
错误答案
最简单的情况是在零知识证明中提供错误的答案。为此,我们进入 zkDig 并修改第 91 行 (在新标签页中打开):
proof.inputs[3] = "0x" + "1".padStart(64, "0")
这意味着无论正确答案是什么,我们都将始终声称有一颗炸弹。尝试玩一下这个版本,你会在 pnpm dev 屏幕的 server 选项卡中看到这个错误:
cause: {
code: 3,
message: 'execution reverted: revert: Zero knowledge verification fail',
data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
e206661696c'
},
因此,这种作弊方式会失败。
错误证明
如果我们提供正确的信息,但证明数据是错误的,会发生什么?现在,将第 91 行替换为:
proof.proof = {
a: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
b: [
["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
],
c: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
}
它仍然会失败,但现在失败没有给出原因,因为它发生在验证者调用期间。
用户如何验证零信任代码?
智能合约相对容易验证。通常,开发者将源代码发布到区块浏览器,区块浏览器会验证源代码是否确实编译为合约部署交易中的代码。在 MUD System 的情况下,这会稍微复杂一些 (在新标签页中打开),但差别不大。
对于零知识来说,这要困难得多。验证者包含一些常量并对它们进行一些计算。这并不能告诉你正在证明什么。
function verifyingKey() pure internal returns (VerifyingKey memory vk) {
vk.alpha = Pairing.G1Point(uint256(0x0f43f4fe7b5c2326fed4ac6ed2f4003ab9ab4ea6f667c2bdd77afb068617ee16), uint256(0x25a77832283f9726935219b5f4678842cda465631e72dbb24708a97ba5d0ce6f));
vk.beta = Pairing.G2Point([uint256(0x2cebd0fbd21aca01910581537b21ae4fed46bc0e524c055059aa164ba0a6b62b), uint256(0x18fd4a7bc386cf03a95af7163d5359165acc4e7961cb46519e6d9ee4a1e2b7e9)], [uint256(0x11449dee0199ef6d8eebfe43b548e875c69e7ce37705ee9a00c81fe52f11a009), uint256(0x066d0c83b32800d3f335bb9e8ed5e2924cf00e77e6ec28178592eac9898e1a00)]);
解决方案是(至少在区块浏览器将 Zokrates 验证添加到其用户界面之前),应用程序开发者公开 Zokrates 程序,并让至少一部分用户使用适当的验证密钥自行编译它们。
为此,请执行以下操作:
-
创建一个包含 Zokrates 程序的文件
dig.zok。下面的代码假设你保留了原始地图大小,即 10x5。import "utils/pack/bool/pack128.zok" as pack128; import "hashes/poseidon/poseidon.zok" as poseidon; def hashMap(bool[12][7] map) -> field { bool[512] mut map1d = [false; 512]; u32 mut counter = 0; for u32 x in 0..12 { for u32 y in 0..7 { map1d[counter] = map[x][y]; counter = counter+1; } } field[4] hashMe = [ pack128(map1d[0..128]), pack128(map1d[128..256]), pack128(map1d[256..384]), pack128(map1d[384..512]) ]; return poseidon(hashMe); } // 位置 (x,y) 的地雷数量 def map2mineCount(bool[12][7] map, u32 x, u32 y) -> u8 { return if map[x+1][y+1] { 1 } else { 0 }; } def main(private bool[12][7] map, u32 x, u32 y) -> (field, u8) { return (hashMap(map) , if map2mineCount(map, x, y) > 0 { 0xFF } else { map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) + map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) + map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1) } ); } -
编译 Zokrates 代码并创建验证密钥。验证密钥必须使用与原始服务器中相同的熵来创建,在本例中为空字符串 (在新标签页中打开)。
zokrates compile --input dig.zok zokrates setup -e "" -
自行创建 Solidity 验证者,并验证它在功能上是否与区块链上的验证者相同(服务器添加了注释,但这并不重要)。
zokrates export-verifier diff verifier.sol ~/20240901-secret-state/packages/contracts/src/verifier.sol
设计决策
在任何足够复杂的应用程序中,都会存在相互竞争的设计目标,需要进行权衡。让我们来看看其中的一些权衡,以及为什么当前的解决方案优于其他选项。
为什么使用零知识
对于扫雷游戏,你其实并不需要零知识。服务器可以一直保存地图,然后在游戏结束时将其全部显示出来。接着,在游戏结束时,智能合约可以计算地图哈希,验证其是否匹配,如果不匹配,则惩罚服务器或完全作废该游戏。
我没有使用这种更简单的解决方案,因为它只适用于具有明确结束状态的短期游戏。当一个游戏可能是无限的(例如自主世界 (在新标签页中打开)的情况),你需要一种在_不_泄露状态的情况下证明状态的解决方案。
作为一篇教程,本文需要一个易于理解的简短游戏,但这种技术对于较长的游戏最为有用。
为什么选择 Zokrates?
Zokrates (在新标签页中打开) 并不是唯一可用的零知识库,但它类似于普通的命令式 (在新标签页中打开)编程语言,并且支持布尔变量。
对于具有不同要求的应用程序,你可能更倾向于使用 Circum (在新标签页中打开) 或 Cairo (在新标签页中打开)。
何时编译 Zokrates
在这个程序中,我们每次服务器启动时 (在新标签页中打开)都会编译 Zokrates 程序。这显然是对资源的浪费,但这是一篇教程,为了简单起见进行了优化。
如果我正在编写一个生产级别的应用程序,我会检查是否有一个包含此雷区大小的已编译 Zokrates 程序的文件,如果有,则使用它。在链上部署验证者合约也是如此。
创建验证者和证明者密钥
密钥创建 (在新标签页中打开)是另一种纯计算,对于给定的雷区大小,不需要进行多次。同样,为了简单起见,它只执行一次。
此外,我们可以使用设置仪式 (在新标签页中打开)。设置仪式的优点是,你需要每个参与者的熵或某些中间结果才能在零知识证明上作弊。如果至少有一位仪式参与者是诚实的并删除了该信息,那么零知识证明就可以免受某些攻击。然而,_没有机制_可以验证信息是否已从所有地方删除。如果零知识证明至关重要,你会希望参与设置仪式。
在这里,我们依赖于有数十名参与者的永久 tau 幂(perpetual powers of tau) (在新标签页中打开)。它可能足够安全,而且简单得多。我们也没有在密钥创建期间添加熵,这使得用户更容易验证零知识配置。
在哪里验证
我们可以在链上(这会消耗 Gas)或在客户端中(使用 verify (在新标签页中打开))验证零知识证明。我选择了前者,因为这让你只需验证验证者一次,然后就可以相信只要它的合约地址保持不变,它就不会改变。如果在客户端进行验证,你每次下载客户端时都必须验证收到的代码。
此外,虽然这个游戏是单人游戏,但许多区块链游戏都是多人游戏。链上验证意味着你只需验证一次零知识证明。在客户端进行验证则需要每个客户端独立验证。
在 TypeScript 还是 Zokrates 中展平地图?
一般来说,当处理既可以在 TypeScript 中进行,也可以在 Zokrates 中进行时,最好在 TypeScript 中进行,因为它要快得多,并且不需要零知识证明。例如,这就是为什么我们不向 Zokrates 提供哈希并让它验证其是否正确的原因。哈希处理必须在 Zokrates 内部完成,但返回的哈希与链上哈希之间的匹配可以在其外部进行。
然而,我们仍然在 Zokrates 中展平地图 (在新标签页中打开),尽管我们本可以在 TypeScript 中完成。原因在于,在我看来,其他选项更糟。
-
向 Zokrates 代码提供一个一维布尔数组,并使用诸如
x*(height+2) +y的表达式来获取二维地图。这会使代码 (在新标签页中打开)变得有些复杂,所以我认为对于一篇教程来说,性能提升是不值得的。 -
将一维数组和二维数组都发送给 Zokrates。然而,这个解决方案并没有给我们带来任何好处。Zokrates 代码必须验证提供给它的一维数组确实是二维数组的正确表示。因此不会有任何性能提升。
-
在 Zokrates 中展平二维数组。这是最简单的选项,所以我选择了它。
在哪里存储地图
在这个应用程序中,gamesInProgress (在新标签页中打开) 只是内存中的一个变量。这意味着如果你的服务器崩溃并需要重启,它存储的所有信息都会丢失。玩家不仅无法继续他们的游戏,甚至无法开始新游戏,因为链上组件认为他们仍有游戏在进行中。
对于生产系统来说,这显然是糟糕的设计,在生产系统中,你会将这些信息存储在数据库中。我在这里使用变量的唯一原因是因为这是一篇教程,简单性是主要考虑因素。
结论:在什么条件下这种技术是合适的?
所以,现在你知道如何编写一个带有服务器的游戏,该服务器存储不属于链上的秘密状态。但在什么情况下你应该这样做呢?主要有两个考虑因素。
-
长期运行的游戏:如上所述,在短期游戏中,你可以在游戏结束后直接发布状态并验证一切。但当游戏需要很长或无限期的时间,并且状态需要保持秘密时,这就行不通了。
-
可接受一定程度的中心化:零知识证明可以验证完整性,即某个实体没有伪造结果。但它们无法确保该实体仍然可用并回复消息。在可用性也需要去中心化的情形下,零知识证明并不是一个充分的解决方案,你需要多方计算 (在新标签页中打开)。
致谢
- Alvaro Alonso 阅读了本文的草稿,并澄清了我对 Zokrates 的一些误解。
任何遗留的错误均由我本人负责。
