使用零知识实现秘密状态
区块链上没有秘密。发布在区块链上的所有内容都向所有人公开,任何人都可以读取。这是必要的,因为区块链的基础是任何人都可以对其进行验证。然而,游戏通常依赖于秘密状态。例如,如果你可以直接在区块浏览器上查看地图,那么扫雷 (opens in a new tab)游戏就完全失去了意义。
最简单的解决方案是使用服务器组件来保存秘密状态。然而,我们使用区块链的原因是为了防止游戏开发者作弊。我们需要确保服务器组件的诚实性。服务器可以提供状态的哈希,并使用零知识证明来证明用于计算移动结果的状态是正确的。
阅读本文后,你将了解如何创建这种保存秘密状态的服务器、用于显示状态的客户端,以及用于两者之间通信的链上组件。我们将使用的主要工具包括:
| 工具 | 用途 | 验证版本 |
|---|---|---|
| Zokrates (opens in a new tab) | 零知识证明及其验证 | 1.1.9 |
| TypeScript (opens in a new tab) | 用于服务器和客户端的编程语言 | 5.4.2 |
| Node (opens in a new tab) | 运行服务器 | 20.18.2 |
| Viem (opens in a new tab) | 与区块链通信 | 2.9.20 |
| MUD (opens in a new tab) | 链上数据管理 | 2.0.12 |
| React (opens in a new tab) | 客户端用户界面 | 18.2.0 |
| Vite (opens in a new tab) | 提供客户端代码服务 | 4.2.1 |
扫雷游戏示例
扫雷 (opens in a new tab) 是一款包含隐藏雷区地图的游戏。玩家选择在特定位置挖掘。如果该位置有地雷,游戏结束。否则,玩家将获得该位置周围八个方块中的地雷数量。
此应用程序使用 MUD (opens in a new tab) 编写,该框架允许我们使用键值数据库 (opens in a new tab)在链上存储数据,并自动将该数据与链下组件同步。除了同步之外,MUD 还可以轻松提供访问控制,并让其他用户能够无许可地扩展 (opens in a new tab)我们的应用程序。
运行扫雷游戏示例
要运行扫雷游戏示例:
-
确保你已安装必备组件 (opens in a new tab):Node (opens in a new tab)、Foundry (opens in a new tab)、
git(opens in a new tab)、pnpm(opens in a new tab) 和mprocs(opens in a new tab)。 -
克隆代码库。
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 (opens in a new tab) 区块链)并等待。
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
-
-
现在你可以浏览到客户端 (opens in a new tab),点击 New Game(新游戏),然后开始游玩。
表
我们需要在链上建立几个表 (opens in a new tab)。
-
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:未处理的在特定游戏中特定位置挖掘的请求。这是一个链下表 (opens in a new tab),这意味着它不会被写入 EVM 存储,只能使用事件在链下读取。
执行和数据流
这些流程协调客户端、链上组件和服务器之间的执行。
初始化
当你运行 mprocs 时,会发生以下步骤:
-
mprocs(opens in a new tab) 运行四个组件:- Anvil (opens in a new tab),运行本地区块链
- Contracts (opens in a new tab),编译(如果需要)并部署 MUD 的合约
- Client (opens in a new tab),运行 Vite (opens in a new tab) 以向 Web 浏览器提供 UI 和客户端代码。
- Server (opens in a new tab),执行服务器操作
-
contracts包部署 MUD 合约,然后运行PostDeploy.s.sol脚本 (opens in a new tab)。此脚本设置配置。来自 GitHub 的代码指定了一个包含八个地雷的 10x5 雷区 (opens in a new tab)。 -
服务器 (opens in a new tab)首先设置 MUD (opens in a new tab)。除其他事项外,这会激活数据同步,以便在服务器内存中存在相关表的副本。
-
服务器订阅一个函数,以便在
Configuration表更改时 (opens in a new tab)执行。此函数 (opens in a new tab)在PostDeploy.s.sol执行并修改表后被调用。 -
当服务器初始化函数获得配置后,它会调用
zkFunctions(opens in a new tab) 来初始化服务器的零知识部分。这必须在我们获得配置之后才能发生,因为零知识函数必须将雷区的宽度和高度作为常量。 -
在服务器的零知识部分初始化之后,下一步是将零知识验证合约部署到区块链 (opens in a new tab)并在 MUD 中设置验证者地址。
-
最后,我们订阅更新,以便在玩家请求开始新游戏 (opens in a new tab)或在现有游戏中挖掘 (opens in a new tab)时能够看到。
新游戏
当玩家请求新游戏时,会发生以下情况。
-
如果该玩家没有正在进行的游戏,或者有一个游戏但 gameId 为零,客户端将显示一个新游戏按钮 (opens in a new tab)。当用户按下此按钮时,React 会运行
newGame函数 (opens in a new tab)。 -
newGame(opens in a new tab) 是一个System调用。在 MUD 中,所有调用都通过World合约路由,在大多数情况下,你调用<namespace>__<function name>。在这种情况下,调用的是app__newGame,然后 MUD 将其路由到GameSystem中的newGame(opens in a new tab)。 -
链上函数检查玩家是否没有正在进行的游戏,如果没有,则将请求添加到
PendingGame表中 (opens in a new tab)。 -
服务器检测到
PendingGame中的更改并运行订阅的函数 (opens in a new tab)。此函数调用newGame(opens in a new tab),后者又调用createGame(opens in a new tab)。 -
createGame做的第一件事是创建一个包含适当数量地雷的随机地图 (opens in a new tab)。然后,它调用makeMapBorders(opens in a new tab) 创建一个带有空白边界的地图,这对于 Zokrates 是必需的。最后,createGame调用calculateMapHash,以获取地图的哈希,该哈希用作游戏 ID。 -
newGame函数将新游戏添加到gamesInProgress中。 -
服务器做的最后一件事是调用
app__newGameResponse(opens in a new tab),这是在链上的。此函数位于不同的System(即ServerSystem(opens in a new tab))中,以启用访问控制。访问控制在 MUD 配置文件 (opens in a new tab)mud.config.ts(opens in a new tab) 中定义。访问列表仅允许单个地址调用
System。这限制了对服务器函数的访问,使其仅限于单个地址,因此没有人可以冒充服务器。 -
链上组件更新相关表:
- 在
PlayerGame中创建游戏。 - 在
GamePlayer中设置反向映射。 - 从
PendingGame中删除请求。
- 在
-
服务器识别出
PendingGame中的更改,但不执行任何操作,因为wantsGame(opens in a new tab) 为 false。 -
在客户端上,
gameRecord(opens in a new tab) 设置为玩家地址的PlayerGame条目。当PlayerGame更改时,gameRecord也会更改。 -
如果
gameRecord中有值,并且游戏尚未分出胜负,客户端将显示地图 (opens in a new tab)。
挖掘
-
玩家点击地图单元格的按钮 (opens in a new tab),这会调用
dig函数 (opens in a new tab)。此函数在链上调用dig(opens in a new tab)。 -
链上组件执行一些健全性检查 (opens in a new tab),如果成功,则将挖掘请求添加到
PendingDig(opens in a new tab) 中。 -
服务器检测到
PendingDig中的更改 (opens in a new tab)。如果有效 (opens in a new tab),它会调用零知识代码 (opens in a new tab)(如下所述)以生成结果及其有效的证明。 -
服务器 (opens in a new tab)在链上调用
digResponse(opens in a new tab)。 -
digResponse做两件事。首先,它检查零知识证明 (opens in a new tab)。然后,如果证明通过,它会调用processDigResult(opens in a new tab) 来实际处理结果。 -
processDigResult检查游戏是否已失败 (opens in a new tab)或获胜 (opens in a new tab),并更新链上地图Map(opens in a new tab)。 -
客户端自动获取更新并更新显示给玩家的地图 (opens in a new tab),并在适用的情况下告诉玩家是赢还是输。
使用 Zokrates
在上面解释的流程中,我们跳过了零知识部分,将其视为一个黑盒。现在让我们打开它,看看这些代码是如何编写的。
对地图进行哈希处理
我们可以使用这段 JavaScript 代码 (opens in a new tab)来实现 Poseidon (opens in a new tab),即我们使用的 Zokrates 哈希函数。然而,虽然这样做会更快,但也比直接使用 Zokrates 哈希函数更复杂。这是一个教程,因此代码针对简单性而非性能进行了优化。因此,我们需要两个不同的 Zokrates 程序,一个仅用于计算地图的哈希(hash),另一个用于实际创建地图上某个位置挖掘结果的零知识证明(dig)。
哈希函数
这是计算地图哈希的函数。我们将逐行查看这段代码。
import "hashes/poseidon/poseidon.zok" as poseidon;
import "utils/pack/bool/pack128.zok" as pack128;
这两行从 Zokrates 标准库 (opens in a new tab)导入了两个函数。第一个函数 (opens in a new tab)是 Poseidon 哈希 (opens in a new tab)。它接收一个 field 元素 (opens in a new tab)数组并返回一个 field。
Zokrates 中的 field 元素通常小于 256 位,但相差不大。为了简化代码,我们将地图限制为最多 512 位,并对包含四个 field 的数组进行哈希处理,在每个 field 中我们仅使用 128 位。pack128 函数 (opens in a new tab)为此目的将 128 位数组转换为 field。
def hashMap(bool[${width+2}][${height+2}] map) -> field {
这一行开始定义一个函数。hashMap 接收一个名为 map 的参数,这是一个二维 bool(ean) 数组。地图的大小为 width+2 乘 height+2,原因将在下文解释。
我们可以使用 ${width+2} 和 ${height+2},因为 Zokrates 程序在此应用程序中作为模板字符串 (opens in a new tab)存储。${ 和 } 之间的代码由 JavaScript 计算,这样该程序就可以用于不同的地图大小。地图参数周围有一个宽度为 1 个位置的无炸弹边界,这就是我们需要在宽度和高度上加 2 的原因。
返回值是一个包含哈希的 field。
bool[512] mut map1d = [false; 512];
地图是二维的。然而,pack128 函数不适用于二维数组。因此,我们首先使用 map1d 将地图展平为 512 字节的数组。默认情况下,Zokrates 变量是常量,但我们需要在循环中为该数组赋值,因此我们将其定义为 mut (opens in a new tab)。
我们需要初始化该数组,因为 Zokrates 没有 undefined。[false; 512] 表达式表示一个包含 512 个 false 值的数组 (opens in a new tab)。
u32 mut counter = 0;
我们还需要一个计数器来区分我们在 map1d 中已经填充的位和尚未填充的位。
for u32 x in 0..${width+2} {
这就是在 Zokrates 中声明 for 循环 (opens in a new tab)的方法。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 };
}
为什么需要地图边界
零知识证明使用算术电路 (opens in a new tab),它没有与 if 语句简单等效的结构。相反,它们使用等效的条件运算符 (opens in a new tab)。如果 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 代码 (opens in a new tab)中使用它。
包含 Zokrates 定义的库称为 zero-knowledge.ts (opens in a new tab)。
import { initialize as zokratesInitialize } from "zokrates-js"
导入 Zokrates JavaScript 绑定 (opens in a new tab)。我们只需要 initialize (opens in a new tab) 函数,因为它返回一个解析为所有 Zokrates 定义的 promise。
export const zkFunctions = async (width: number, height: number) : Promise<any> => {
与 Zokrates 本身类似,我们也只导出一个函数,该函数也是异步的 (opens in a new tab)。当它最终返回时,它会提供几个函数,我们将在下面看到。
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
在生产系统上,我们可能会使用更复杂的设置仪式 (opens in a new tab),但这对于演示来说已经足够了。用户知道证明者密钥并不是问题——除非事情是真的,否则他们仍然无法使用它来证明。因为我们指定了熵(第二个参数 ""),所以结果总是相同的。
注意: 编译 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 (opens in a new tab) 函数实际运行 Zokrates 程序。它返回一个包含两个字段的结构:output(作为 JSON 字符串的程序输出)和 witness(创建结果的零知识证明所需的信息)。在这里我们只需要输出。
输出是一个形式为 "31337" 的字符串,即用引号括起来的十进制数。但我们为 viem 所需的输出是形式为 0x60A7 的十六进制数。因此,我们使用 .slice(1,-1) 去除引号,然后使用 BigInt 将剩余的字符串(即十进制数)转换为 BigInt (opens in a new tab)。.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 (opens in a new tab) 并返回证明。
const solidityVerifier = `
// Map size: ${width} x ${height}
\n${zokrates.exportSolidityVerifier(verifierKey)}
`
一个 Solidity 验证者,这是一个我们可以部署到区块链上的智能合约,用于验证由 digCompiled.program 生成的证明。
return {
zkDig,
calculateMapHash,
solidityVerifier,
}
}
最后,返回其他代码可能需要的所有内容。
安全测试
安全测试非常重要,因为功能性错误最终会暴露出来。但如果应用程序不安全,这种隐患很可能会隐藏很长时间,直到有人作弊并卷走属于他人的资源时才会被发现。
权限
在这个游戏中有一个特权实体,即服务器。它是唯一被允许调用 ServerSystem (opens in a new tab) 中功能的用户。我们可以使用 cast (opens in a new tab) 来验证对许可型函数的调用是否仅限于服务器账户。
服务器的私钥位于 setupNetwork.ts 中 (opens in a new tab)。
-
在运行
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 (opens in a new tab) 中的其他函数。
零知识滥用
验证 Zokrates 的数学原理超出了本教程的范围(也超出了我的能力)。但是,我们可以对零知识代码运行各种检查,以验证如果操作不正确,它就会失败。所有这些测试都需要我们更改 zero-knowledge.ts (opens in a new tab) 并重新启动整个应用程序。仅仅重启服务器进程是不够的,因为这会使应用程序处于一种不可能的状态(玩家有一个正在进行的游戏,但该游戏对服务器不再可用)。
错误答案
最简单的情况是在零知识证明中提供错误的答案。为此,我们进入 zkDig 并修改第 91 行 (opens in a new tab):
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 的情况下,这会稍微复杂一些 (opens in a new tab),但差别不大。
对于零知识来说,这要困难得多。验证者包含一些常量并对它们进行一些计算。这并不能告诉你正在证明什么。
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 代码并创建验证密钥。验证密钥必须使用与原始服务器中相同的熵来创建,在本例中为空字符串 (opens in a new tab)。
zokrates compile --input dig.zok zokrates setup -e "" -
自行创建 Solidity 验证者,并验证它在功能上是否与区块链上的验证者相同(服务器添加了注释,但这并不重要)。
zokrates export-verifier diff verifier.sol ~/20240901-secret-state/packages/contracts/src/verifier.sol
设计决策
在任何足够复杂的应用程序中,都会存在相互竞争的设计目标,需要进行权衡。让我们来看看其中的一些权衡,以及为什么当前的解决方案优于其他选项。
为什么使用零知识
对于扫雷游戏,你其实并不需要零知识。服务器可以一直保存地图,然后在游戏结束时将其全部显示出来。接着,在游戏结束时,智能合约可以计算地图哈希,验证其是否匹配,如果不匹配,则惩罚服务器或完全作废该游戏。
我没有使用这种更简单的解决方案,因为它只适用于具有明确结束状态的短期游戏。当一个游戏可能是无限的(例如自主世界 (opens in a new tab)的情况),你需要一种在_不_泄露状态的情况下证明状态的解决方案。
作为一篇教程,本文需要一个易于理解的简短游戏,但这种技术对于较长的游戏最为有用。
为什么选择 Zokrates?
Zokrates (opens in a new tab) 并不是唯一可用的零知识库,但它类似于普通的命令式 (opens in a new tab)编程语言,并且支持布尔变量。
对于具有不同要求的应用程序,你可能更倾向于使用 Circum (opens in a new tab) 或 Cairo (opens in a new tab)。
何时编译 Zokrates
在这个程序中,我们每次服务器启动时 (opens in a new tab)都会编译 Zokrates 程序。这显然是对资源的浪费,但这是一篇教程,为了简单起见进行了优化。
如果我正在编写一个生产级别的应用程序,我会检查是否有一个包含此雷区大小的已编译 Zokrates 程序的文件,如果有,则使用它。在链上部署验证者合约也是如此。
创建验证者和证明者密钥
密钥创建 (opens in a new tab)是另一种纯计算,对于给定的雷区大小,不需要进行多次。同样,为了简单起见,它只执行一次。
此外,我们可以使用设置仪式 (opens in a new tab)。设置仪式的优点是,你需要每个参与者的熵或某些中间结果才能在零知识证明上作弊。如果至少有一位仪式参与者是诚实的并删除了该信息,那么零知识证明就可以免受某些攻击。然而,_没有机制_可以验证信息是否已从所有地方删除。如果零知识证明至关重要,你会希望参与设置仪式。
在这里,我们依赖于有数十名参与者的永久 tau 幂(perpetual powers of tau) (opens in a new tab)。它可能足够安全,而且简单得多。我们也没有在密钥创建期间添加熵,这使得用户更容易验证零知识配置。
在哪里验证
我们可以在链上(这会消耗 Gas)或在客户端中(使用 verify (opens in a new tab))验证零知识证明。我选择了前者,因为这让你只需验证验证者一次,然后就可以相信只要它的合约地址保持不变,它就不会改变。如果在客户端进行验证,你每次下载客户端时都必须验证收到的代码。
此外,虽然这个游戏是单人游戏,但许多区块链游戏都是多人游戏。链上验证意味着你只需验证一次零知识证明。在客户端进行验证则需要每个客户端独立验证。
在 TypeScript 还是 Zokrates 中展平地图?
一般来说,当处理既可以在 TypeScript 中进行,也可以在 Zokrates 中进行时,最好在 TypeScript 中进行,因为它要快得多,并且不需要零知识证明。例如,这就是为什么我们不向 Zokrates 提供哈希并让它验证其是否正确的原因。哈希处理必须在 Zokrates 内部完成,但返回的哈希与链上哈希之间的匹配可以在其外部进行。
然而,我们仍然在 Zokrates 中展平地图 (opens in a new tab),尽管我们本可以在 TypeScript 中完成。原因在于,在我看来,其他选项更糟。
-
向 Zokrates 代码提供一个一维布尔数组,并使用诸如
x*(height+2) +y的表达式来获取二维地图。这会使代码 (opens in a new tab)变得有些复杂,所以我认为对于一篇教程来说,性能提升是不值得的。 -
将一维数组和二维数组都发送给 Zokrates。然而,这个解决方案并没有给我们带来任何好处。Zokrates 代码必须验证提供给它的一维数组确实是二维数组的正确表示。因此不会有任何性能提升。
-
在 Zokrates 中展平二维数组。这是最简单的选项,所以我选择了它。
在哪里存储地图
在这个应用程序中,gamesInProgress (opens in a new tab) 只是内存中的一个变量。这意味着如果你的服务器崩溃并需要重启,它存储的所有信息都会丢失。玩家不仅无法继续他们的游戏,甚至无法开始新游戏,因为链上组件认为他们仍有游戏在进行中。
对于生产系统来说,这显然是糟糕的设计,在生产系统中,你会将这些信息存储在数据库中。我在这里使用变量的唯一原因是因为这是一篇教程,简单性是主要考虑因素。
结论:在什么条件下这种技术是合适的?
所以,现在你知道如何编写一个带有服务器的游戏,该服务器存储不属于链上的秘密状态。但在什么情况下你应该这样做呢?主要有两个考虑因素。
-
长期运行的游戏:如上所述,在短期游戏中,你可以在游戏结束后直接发布状态并验证一切。但当游戏需要很长或无限期的时间,并且状态需要保持秘密时,这就行不通了。
-
可接受一定程度的中心化:零知识证明可以验证完整性,即某个实体没有伪造结果。但它们无法确保该实体仍然可用并回复消息。在可用性也需要去中心化的情形下,零知识证明并不是一个充分的解决方案,你需要多方计算 (opens in a new tab)。
点击此处查看我的更多作品 (opens in a new tab)。
致谢
- Alvaro Alonso 阅读了本文的草稿,并澄清了我对 Zokrates 的一些误解。
任何遗留的错误均由我本人负责。
