跳转至主要内容

编写一个保护隐私的应用程序专用 Plasma

零知识
服务器
链下
隐私
高级
Ori Pomerantz
2025年10月15日
45 分钟阅读

简介

Rollup 相反,Plasma 使用以太坊主网保证完整性,但不保证可用性。 在本文中,我们编写了一个行为类似于 Plasma 的应用程序,其中以太坊保证完整性(未经授权的更改),但不保证可用性(中心化组件可能宕机并禁用整个系统)。

我们在此编写的应用程序是一个保护隐私的银行。 不同的地址拥有带余额的帐户,它们可以向其他帐户发送资金 (ETH)。 银行会发布状态(帐户及其余额)和交易的哈希值,但会将实际余额保留在链下,以保持隐私。

设计

这不是一个生产就绪的系统,而是一个教学工具。 因此,它是基于几个简化的假设编写的。

  • 固定帐户池。 帐户数量是特定的,每个帐户都属于一个预定地址。 这使得系统更加简单,因为在零知识证明中处理可变大小的数据结构很困难。 对于生产就绪系统,我们可以使用 Merkle 根作为状态哈希,并为所需余额提供 Merkle 证明。

  • 内存存储。 在生产系统中,我们需要将所有帐户余额写入磁盘,以在重启时保留它们。 在这里,如果信息丢失了也没关系。

  • 仅限转账。 生产系统需要一种将资产存入银行和取款的方式。 但这里的目的只是为了说明概念,所以这家银行仅限于转账。

零知识证明

在基本层面上,零知识证明表明证明者知道一些数据 Dataprivate,使得一些公共数据 DatapublicDataprivate 之间存在关系 Relationship。 验证者知道 RelationshipDatapublic

为了保护隐私,我们需要将状态和交易设为私有。 但为了确保完整性,我们需要将状态的加密哈希opens in a new tab设为公开。 为了向提交交易的人证明这些交易确实发生过,我们还需要发布交易哈希。

在大多数情况下,Dataprivate 是零知识证明程序的输入,而 Datapublic 是输出。

Dataprivate 中的这些字段:

  • Staten,旧状态
  • Staten+1,新状态
  • Transaction,一个将旧状态更改为新状态的交易。 此交易需要包含以下字段:
    • 目标地址,接收转账的地址
    • 金额,正在转账的金额
    • Nonce,用于确保每笔交易只能处理一次。 源地址不需要在交易中,因为可以从签名中恢复。
  • 签名,一个被授权执行交易的签名。 在我们的案例中,唯一被授权执行交易的地址是源地址。 因为我们的零知识系统的工作方式,除了以太坊签名之外,我们还需要帐户的公钥。

Datapublic 中的字段如下:

  • Hash(Staten),旧状态的哈希
  • Hash(Staten+1),新状态的哈希
  • Hash(Transaction),将状态从 Staten 更改为 Staten+1 的交易哈希。

该关系检查了几个条件:

  • 公共哈希确实是私有字段的正确哈希。
  • 当交易应用于旧状态时,会产生新状态。
  • 签名来自交易的源地址。

由于加密哈希函数的特性,证明这些条件足以确保完整性。

数据结构

主要的数据结构是服务器持有的状态。 对于每个帐户,服务器会跟踪帐户余额和一个 nonceopens in a new tab,用于防止重放攻击opens in a new tab

组件

该系统需要两个组件:

  • 接收交易、处理交易并将哈希与零知识证明一起发布到链上的_服务器_。
  • 一个存储哈希并验证零知识证明以确保状态转换合法的_智能合约_。

数据和控制流

这些是各种组件之间进行通信以实现从一个帐户到另一个帐户的转账的方式。

  1. Web 浏览器提交一个已签名的交易,请求从签名者的帐户向另一个帐户转账。

  2. 服务器验证交易是否有效:

    • 签名者在银行中拥有一个有足够余额的帐户。
    • 收款人在银行中拥有一个帐户。
  3. 服务器通过从签名者的余额中减去转账金额并将其加到收款人的余额中来计算新状态。

  4. 服务器计算一个零知识证明,证明状态变更是有效的。

  5. 服务器向以太坊提交一笔交易,其中包括:

    • 新状态哈希
    • 交易哈希(以便交易发送者知道它已被处理)
    • 证明向新状态的转换有效的零知识证明
  6. 智能合约验证零知识证明。

  7. 如果零知识证明通过验证,智能合约将执行以下操作:

    • 将当前状态哈希更新为新状态哈希
    • 发出一个包含新状态哈希和交易哈希的日志条目

工具

对于客户端代码,我们将使用 Viteopens in a new tabReactopens in a new tabViemopens in a new tabWagmiopens in a new tab。 这些是行业标准工具;如果你不熟悉它们,可以使用本教程

服务器的大部分是使用 Nodeopens in a new tab 以 JavaScript 编写的。 零知识部分是用 Noiropens in a new tab 编写的。 我们需要 1.0.0-beta.10 版本,所以在您按照指示安装 Noiropens in a new tab 后,运行:

1noirup -v 1.0.0-beta.10

我们使用的区块链是 anvil,这是一个本地测试区块链,属于 Foundryopens in a new tab 的一部分。

实现

因为这是一个复杂的系统,我们将分阶段实现它。

第 1 阶段 - 手动零知识

在第一阶段,我们将在浏览器中签署一笔交易,然后手动将信息提供给零知识证明。 零知识代码期望在 server/noir/Prover.toml 中获取该信息(文档在此处opens in a new tab)。

要查看实际操作:

  1. 确保您已安装 Nodeopens in a new tabNoiropens in a new tab。 最好将它们安装在 UNIX 系统上,例如 macOS、Linux 或 WSLopens in a new tab

  2. 下载阶段 1 的代码并启动 Web 服务器以提供客户端代码。

    1git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk
    2cd 250911-zk-bank
    3cd client
    4npm install
    5npm run dev

    这里需要一个 Web 服务器的原因是,为了防止某些类型的欺诈,许多钱包(例如 MetaMask)不接受直接从磁盘提供的文件

  3. 用钱包打开浏览器。

  4. 在钱包中,输入一个新的密码短语。 请注意,这将删除您现有的密码短语,因此_请确保您有备份_。

    密码短语是 test test test test test test test test test test test junk,这是 anvil 的默认测试密码短语。

  5. 浏览到客户端代码opens in a new tab

  6. 连接到钱包并选择您的目标帐户和金额。

  7. 点击 Sign 并签署交易。

  8. Prover.toml 标题下,您会找到文本。 用该文本替换 server/noir/Prover.toml

  9. 执行零知识证明。

    1cd ../server/noir
    2nargo execute

    输出应类似于

    1ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute
    2
    3[zkBank] Circuit witness successfully solved
    4[zkBank] Witness saved to target/zkBank.gz
    5[zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85)
  10. 将最后两个值与您在 Web 浏览器上看到的哈希进行比较,以查看消息是否已正确哈希。

server/noir/Prover.toml

此文件opens in a new tab 显示了 Noir 期望的信息格式。

1message="send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 500 finney (milliEth) 0 "

消息采用文本格式,这使得用户易于理解(在签名时是必要的),也便于 Noir 代码解析。 金额以 finney 为单位,一方面可以实现小数额转账,另一方面也易于阅读。 最后一个数字是 nonceopens in a new tab

字符串长度为 100 个字符。 零知识证明不能很好地处理可变大小的数据,因此通常需要填充数据。

1pubKeyX=["0x83",...,"0x75"]
2pubKeyY=["0x35",...,"0xa5"]
3signature=["0xb1",...,"0x0d"]

这三个参数是固定大小的字节数组。

1[[accounts]]
2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
3balance=100_000
4nonce=0
5
6[[accounts]]
7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
8balance=100_000
9nonce=0
显示全部

这是指定结构数组的方式。 对于每个条目,我们指定地址、余额(以 milliETH 为单位,又称 finneyopens in a new tab) 和下一个 nonce 值。

client/src/Transfer.tsx

此文件opens in a new tab 实现了客户端处理,并生成 server/noir/Prover.toml 文件(包含零知识参数的文件)。

以下是更有趣部分的解释。

1export default attrs => {

此函数创建 Transfer React 组件,其他文件可以导入该组件。

1 const accounts = [
2 "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
3 "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
4 "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
5 "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
6 "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
7 ]

这些是帐户地址,由 test ... test junk` 密码短语创建的地址。 如果您想使用自己的地址,只需修改此定义即可。

1 const account = useAccount()
2 const wallet = createWalletClient({
3 transport: custom(window.ethereum!)
4 })

这些 Wagmi 钩子opens in a new tab 让我们能够访问 viemopens in a new tab 库和钱包。

1 const message = `send ${toAccount} ${ethAmount*1000} finney (milliEth) ${nonce}`.padEnd(100, " ")

这是用空格填充的消息。 每次 useStateopens in a new tab 变量之一发生变化时,组件都会重新绘制,message 也会更新。

1 const sign = async () => {

当用户点击 Sign 按钮时,此函数将被调用。 消息会自动更新,但签名需要用户在钱包中批准,除非必要,否则我们不想请求它。

1 const signature = await wallet.signMessage({
2 account: fromAccount,
3 message,
4 })

要求钱包签署消息opens in a new tab

1 const hash = hashMessage(message)

获取消息哈希。 将其提供给用户以进行调试(Noir 代码的调试)会很有帮助。

1 const pubKey = await recoverPublicKey({
2 hash,
3 signature
4 })

获取公钥opens in a new tab。 这是 Noir ecrecoveropens in a new tab 函数所必需的。

1 setSignature(signature)
2 setHash(hash)
3 setPubKey(pubKey)

设置状态变量。 这样做会重新绘制组件(在 sign 函数退出后)并向用户显示更新后的值。

1 let proverToml = `

Prover.toml 的文本。

1message="${message}"
2
3pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}
4pubKeyY=${hexToArray(pubKey.slice(4+2*32))}

Viem 以 65 字节十六进制字符串的形式为我们提供公钥。 第一个字节是 0x04,一个版本标记。 后面是公钥的 x 的 32 个字节,然后是公钥的 y 的 32 个字节。

然而,Noir 期望以两个字节数组的形式获取此信息,一个用于 x,一个用于 y。 在客户端解析它比在零知识证明中解析它更容易。

请注意,这通常是零知识中的良好实践。 零知识证明内部的代码是昂贵的,因此任何可以在零知识证明外部完成的处理_都应该_在零知识证明外部完成。

1signature=${hexToArray(signature.slice(2,-2))}

签名也以 65 字节的十六进制字符串形式提供。 但是,最后一个字节仅用于恢复公钥。 由于公钥已经提供给 Noir 代码,我们不需要它来验证签名,Noir 代码也不需要它。

1${accounts.map(accountInProverToml).reduce((a,b) => a+b, "")}
2`

提供帐户。

1 setProverToml(proverToml)
2 }
3
4 return (
5 <>
6 <h2>转账</h2>

这是组件的 HTML(更准确地说,是 JSXopens in a new tab)格式。

server/noir/src/main.nr

此文件opens in a new tab是实际的零知识代码。

1use std::hash::pedersen_hash;

Pedersen 哈希opens in a new tabNoir 标准库opens in a new tab提供。 零知识证明通常使用此哈希函数。 与标准哈希函数相比,在算术电路opens in a new tab中计算要容易得多。

1use keccak256::keccak256;
2use dep::ecrecover;

这两个函数是外部库,在 Nargo.tomlopens in a new tab 中定义。 它们的功能正如其名,一个函数用于计算 keccak256 哈希opens in a new tab,另一个函数用于验证以太坊签名并恢复签名者的以太坊地址。

1global ACCOUNT_NUMBER : u32 = 5;

Noir 受到了 Rustopens in a new tab 的启发。 默认情况下,变量是常量。 这就是我们定义全局配置常量的方式。 具体来说,ACCOUNT_NUMBER 是我们存储的帐户数量。

u<number> 命名的数据类型是该位数的无符号整数。 唯一支持的类型是 u8u16u32u64u128

1global FLAT_ACCOUNT_FIELDS : u32 = 2;

如下文所述,此变量用于帐户的 Pedersen 哈希。

1global MESSAGE_LENGTH : u32 = 100;

如上所述,消息长度是固定的。 在此处指定。

1global ASCII_MESSAGE_LENGTH : [u8; 3] = [0x31, 0x30, 0x30];
2global HASH_BUFFER_SIZE : u32 = 26+3+MESSAGE_LENGTH;

EIP-191 签名opens in a new tab 需要一个带有 26 字节前缀的缓冲区,后跟 ASCII 格式的消息长度,最后是消息本身。

1struct Account {
2 balance: u128,
3 address: Field,
4 nonce: u32,
5}

我们存储的关于一个帐户的信息。 Fieldopens in a new tab 是一个数字,通常最多 253 位,可以直接用于实现零知识证明的算术电路opens in a new tab。 这里我们使用 Field 来存储一个 160 位的以太坊地址。

1struct TransferTxn {
2 from: Field,
3 to: Field,
4 amount: u128,
5 nonce: u32
6}

我们为一笔转账交易存储的信息。

1fn flatten_account(account: Account) -> [Field; FLAT_ACCOUNT_FIELDS] {

一个函数定义。 参数是 Account 信息。 结果是一个 Field 变量数组,其长度为 FLAT_ACCOUNT_FIELDS

1 let flat = [
2 account.address,
3 ((account.balance << 32) + account.nonce.into()).into(),
4 ];

数组中的第一个值是帐户地址。 第二个值包括余额和 nonce。 .into() 调用将数字更改为所需的数据类型。 account.nonce 是一个 u32 值,但要将其加到 account.balance « 32(一个 u128 值)上,它需要是一个 u128。 这是第一个 .into()。 第二个将 u128 结果转换为一个 Field,以便它能放入数组中。

1 flat
2}

在 Noir 中,函数只能在末尾返回值(没有提前返回)。 要指定返回值,您需要在函数的右括号之前对其进行求值。

1fn flatten_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] {

此函数将帐户数组转换为 Field 数组,可用作 Petersen 哈希的输入。

1 let mut flat: [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] = [0; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER];

这就是你指定一个可变变量的方式,也就是说,_不是_一个常量。 Noir 中的变量必须总有一个值,所以我们将这个变量初始化为全零。

1 for i in 0..ACCOUNT_NUMBER {

这是一个 for 循环。 请注意,边界是常量。 Noir 循环必须在编译时知道其边界。 原因是算术电路不支持流控制。 在处理 for 循环时,编译器只是将循环内的代码多次放置,每次迭代一次。

1 let fields = flatten_account(accounts[i]);
2 for j in 0..FLAT_ACCOUNT_FIELDS {
3 flat[i*FLAT_ACCOUNT_FIELDS + j] = fields[j];
4 }
5 }
6
7 flat
8}
9
10fn hash_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> Field {
11 pedersen_hash(flatten_accounts(accounts))
12}
显示全部

最后,我们来到了哈希帐户数组的函数。

1fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {
2 let mut account : u32 = ACCOUNT_NUMBER;
3
4 for i in 0..ACCOUNT_NUMBER {
5 if accounts[i].address == address {
6 account = i;
7 }
8 }

此函数查找具有特定地址的帐户。 在标准代码中,此函数会非常低效,因为它会遍历所有帐户,即使在找到地址之后也是如此。

然而,在零知识证明中,没有流控制。 如果我们需要检查一个条件,我们必须每次都检查它。

if 语句也会发生类似的情况。 上面循环中的 if 语句被翻译成这些数学语句。

conditionresult = accounts[i].address == address // 如果相等则为 1,否则为 0

accountnew = conditionresult*i + (1-conditionresult)*accountold

1 assert (account < ACCOUNT_NUMBER, f"{address} does not have an account");
2
3 account
4}

如果断言为假,assertopens in a new tab 函数会导致零知识证明崩溃。 在这种情况下,如果我们找不到具有相关地址的帐户。 要报告地址,我们使用格式化字符串opens in a new tab

1fn apply_transfer_txn(accounts: [Account; ACCOUNT_NUMBER], txn: TransferTxn) -> [Account; ACCOUNT_NUMBER] {

此函数应用转账交易并返回新的帐户数组。

1 let from = find_account(accounts, txn.from);
2 let to = find_account(accounts, txn.to);
3
4 let (txnFrom, txnAmount, txnNonce, accountNonce) =
5 (txn.from, txn.amount, txn.nonce, accounts[from].nonce);

在 Noir 中,我们无法在格式字符串内访问结构元素,因此我们创建了一个可用的副本。

1 assert (accounts[from].balance >= txn.amount,
2 f"{txnFrom} does not have {txnAmount} finney");
3
4 assert (accounts[from].nonce == txn.nonce,
5 f"Transaction has nonce {txnNonce}, but the account is expected to use {accountNonce}");

这是两个可能使交易无效的条件。

1 let mut newAccounts = accounts;
2
3 newAccounts[from].balance -= txn.amount;
4 newAccounts[from].nonce += 1;
5 newAccounts[to].balance += txn.amount;
6
7 newAccounts
8}

创建新的帐户数组,然后返回它。

1fn readAddress(messageBytes: [u8; MESSAGE_LENGTH]) -> Field

该函数从消息中读取地址。

1{
2 let mut result : Field = 0;
3
4 for i in 7..47 {

地址总是 20 字节(又名 40 个十六进制数字)长,从第 7 个字符开始。

1 result *= 0x10;
2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-9
3 result += (messageBytes[i]-48).into();
4 }
5 if messageBytes[i] >= 65 & messageBytes[i] <= 70 { // A-F
6 result += (messageBytes[i]-65+10).into()
7 }
8 if messageBytes[i] >= 97 & messageBytes[i] <= 102 { // a-f
9 result += (messageBytes[i]-97+10).into()
10 }
11 }
12
13 result
14}
15
16fn readAmountAndNonce(messageBytes: [u8; MESSAGE_LENGTH]) -> (u128, u32)
显示全部

从消息中读取金额和 nonce。

1{
2 let mut amount : u128 = 0;
3 let mut nonce: u32 = 0;
4 let mut stillReadingAmount: bool = true;
5 let mut lookingForNonce: bool = false;
6 let mut stillReadingNonce: bool = false;

在消息中,地址之后的第一个数字是 finney(又称 千分之一 ETH)的转账金额。 第二个数字是 nonce。 它们之间的任何文本都将被忽略。

1 for i in 48..MESSAGE_LENGTH {
2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-9
3 let digit = (messageBytes[i]-48);
4
5 if stillReadingAmount {
6 amount = amount*10 + digit.into();
7 }
8
9 if lookingForNonce { // 我们刚找到它
10 stillReadingNonce = true;
11 lookingForNonce = false;
12 }
13
14 if stillReadingNonce {
15 nonce = nonce*10 + digit.into();
16 }
17 } else {
18 if stillReadingAmount {
19 stillReadingAmount = false;
20 lookingForNonce = true;
21 }
22 if stillReadingNonce {
23 stillReadingNonce = false;
24 }
25 }
26 }
27
28 (amount, nonce)
29}
显示全部

返回一个元组opens in a new tab是从函数返回多个值的 Noir 方式。

1fn readTransferTxn(message: str<MESSAGE_LENGTH>) -> TransferTxn
2{
3 let mut txn: TransferTxn = TransferTxn { from: 0, to: 0, amount:0, nonce:0 };
4 let messageBytes = message.as_bytes();
5
6 txn.to = readAddress(messageBytes);
7 let (amount, nonce) = readAmountAndNonce(messageBytes);
8 txn.amount = amount;
9 txn.nonce = nonce;
10
11 txn
12}
显示全部

此函数将消息转换为字节,然后将金额转换为 TransferTxn

1// 相当于 Viem 的 hashMessage
2// https://viem.sh/docs/utilities/hashMessage#hashmessage
3fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {

我们能够对帐户使用 Pedersen 哈希,因为它们只在零知识证明内部进行哈希。 然而,在此代码中,我们需要检查由浏览器生成的消息签名。 为此,我们需要遵循 EIP 191opens in a new tab 中的以太坊签名格式。 这意味着我们需要创建一个组合缓冲区,其中包含标准前缀、ASCII 格式的消息长度和消息本身,并使用以太坊标准 keccak256 对其进行哈希。

1 // ASCII 前缀
2 let prefix_bytes = [
3 0x19, // \x19
4 0x45, // 'E'
5 0x74, // 't'
6 0x68, // 'h'
7 0x65, // 'e'
8 0x72, // 'r'
9 0x65, // 'e'
10 0x75, // 'u'
11 0x6D, // 'm'
12 0x20, // ' '
13 0x53, // 'S'
14 0x69, // 'i'
15 0x67, // 'g'
16 0x6E, // 'n'
17 0x65, // 'e'
18 0x64, // 'd'
19 0x20, // ' '
20 0x4D, // 'M'
21 0x65, // 'e'
22 0x73, // 's'
23 0x73, // 's'
24 0x61, // 'a'
25 0x67, // 'g'
26 0x65, // 'e'
27 0x3A, // ':'
28 0x0A // '\n'
29 ];
显示全部

为避免应用程序要求用户签署可用作交易或其他用途的消息的情况,EIP 191 规定所有已签名的消息都以字符 0x19(不是有效的 ASCII 字符)开头,后跟 Ethereum Signed Message: 和一个换行符。

1 let mut buffer: [u8; HASH_BUFFER_SIZE] = [0u8; HASH_BUFFER_SIZE];
2 for i in 0..26 {
3 buffer[i] = prefix_bytes[i];
4 }
5
6 let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();
7
8 if MESSAGE_LENGTH <= 9 {
9 for i in 0..1 {
10 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
11 }
12
13 for i in 0..MESSAGE_LENGTH {
14 buffer[i+26+1] = messageBytes[i];
15 }
16 }
17
18 if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {
19 for i in 0..2 {
20 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
21 }
22
23 for i in 0..MESSAGE_LENGTH {
24 buffer[i+26+2] = messageBytes[i];
25 }
26 }
27
28 if MESSAGE_LENGTH >= 100 {
29 for i in 0..3 {
30 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
31 }
32
33 for i in 0..MESSAGE_LENGTH {
34 buffer[i+26+3] = messageBytes[i];
35 }
36 }
37
38 assert(MESSAGE_LENGTH < 1000, "Messages whose length is over three digits are not supported");
显示全部

处理长度最多为 999 的消息,如果大于此长度则失败。 我添加了此代码,尽管消息长度是常量,但这样做可以更容易地更改它。 在生产系统上,为了更好的性能,您可能会假设 MESSAGE_LENGTH 不会改变。

1 keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
2}

使用以太坊标准 keccak256 函数。

1fn signatureToAddressAndHash(
2 message: str<MESSAGE_LENGTH>,
3 pubKeyX: [u8; 32],
4 pubKeyY: [u8; 32],
5 signature: [u8; 64]
6 ) -> (Field, Field, Field) // 地址、哈希的前 16 个字节、哈希的后 16 个字节
7{

此函数验证签名,这需要消息哈希。 然后,它为我们提供签名地址和消息哈希。 消息哈希以两个 Field 值的形式提供,因为它们在程序的其余部分比字节数组更容易使用。

我们需要使用两个 Field 值,因为字段计算是对一个大数进行opens in a new tab运算,但该数字通常小于 256 位(否则很难在 EVM 中执行这些计算)。

1 let hash = hashMessage(message);
2
3 let mut (hash1, hash2) = (0,0);
4
5 for i in 0..16 {
6 hash1 = hash1*256 + hash[31-i].into();
7 hash2 = hash2*256 + hash[15-i].into();
8 }

hash1hash2 指定为可变变量,并逐字节将哈希写入其中。

1 (
2 ecrecover::ecrecover(pubKeyX, pubKeyY, signature, hash),

这类似于 Solidity 的 ecrecoveropens in a new tab,但有两个重要区别:

  • 如果签名无效,调用会失败一个 assert 并中止程序。
  • 虽然可以从签名和哈希中恢复公钥,但这是可以在外部完成的处理,因此不值得在零知识证明中进行。 如果有人在这里试图欺骗我们,签名验证将会失败。
1 hash1,
2 hash2
3 )
4}
5
6fn main(
7 accounts: [Account; ACCOUNT_NUMBER],
8 message: str<MESSAGE_LENGTH>,
9 pubKeyX: [u8; 32],
10 pubKeyY: [u8; 32],
11 signature: [u8; 64],
12 ) -> pub (
13 Field, // 旧帐户数组的哈希
14 Field, // 新帐户数组的哈希
15 Field, // 消息哈希的前 16 个字节
16 Field, // 消息哈希的后 16 个字节
17 )
显示全部

最后,我们到达 main 函数。 我们需要证明我们有一笔交易,可以有效地将帐户哈希从旧值更改为新值。 我们还需要证明它具有此特定的交易哈希,以便发送者知道他们的交易已处理。

1{
2 let mut txn = readTransferTxn(message);

我们需要 txn 是可变的,因为我们不是从消息中读取“from”地址,而是从签名中读取。

1 let (fromAddress, txnHash1, txnHash2) = signatureToAddressAndHash(
2 message,
3 pubKeyX,
4 pubKeyY,
5 signature);
6
7 txn.from = fromAddress;
8
9 let newAccounts = apply_transfer_txn(accounts, txn);
10
11 (
12 hash_accounts(accounts),
13 hash_accounts(newAccounts),
14 txnHash1,
15 txnHash2
16 )
17}
显示全部

阶段 2 - 添加服务器

在第二阶段,我们添加一个服务器,用于接收和实现来自浏览器的转账交易。

要查看实际操作:

  1. 如果 Vite 正在运行,请停止它。

  2. 下载包含服务器的分支,并确保您拥有所有必需的模块。

    1git checkout 02-add-server
    2cd client
    3npm install
    4cd ../server
    5npm install

    无需编译 Noir 代码,它与您在阶段 1 中使用的代码相同。

  3. 启动服务器。

    1npm run start
  4. 在单独的命令行窗口中,运行 Vite 以提供浏览器代码。

    1cd client
    2npm run dev
  5. 浏览到客户端代码 http://localhost:5173opens in a new tab

  6. 在您发出交易之前,您需要知道 nonce 以及您可以发送的金额。 要获取此信息,请单击 Update account data 并签署消息。

    我们在这里遇到了一个两难的境地。 一方面,我们不希望签署可以重复使用的消息(重放攻击opens in a new tab),这就是我们首先需要 nonce 的原因。 但是,我们还没有 nonce。 解决方案是选择一个只能使用一次且双方都已有的 nonce,例如当前时间。

    这个解决方案的问题在于时间可能不完全同步。 因此,我们改为签署一个每分钟都会变化的值。 这意味着我们受到重放攻击的漏洞窗口最多为一分钟。 考虑到在生产环境中,已签名的请求将受到 TLS 的保护,并且隧道的另一端——服务器——已经可以泄露余额和 nonce(它必须知道这些才能工作),这是一个可以接受的风险。

  7. 一旦浏览器取回余额和 nonce,它就会显示转账表单。 选择目标地址和金额,然后单击 Transfer。 签署此请求。

  8. 要查看转账,请更新帐户数据或查看运行服务器的窗口。 服务器每次状态更改时都会记录日志。

    1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
    2
    3> server@1.0.0 start
    4> node --experimental-json-modules index.mjs
    5
    6Listening on port 3000
    7Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed
    8New state:
    90xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 64000 (1)
    100x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 100000 (0)
    110x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    120x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)
    130x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    14Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 processed
    15New state:
    160xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 56800 (2)
    170x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)
    180x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    190x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)
    200x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    21Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 processed
    22New state:
    230xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 53800 (3)
    240x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)
    250x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    260x90F79bf6EB2c4f870365E785982E1f101E93b906 has 139000 (0)
    270x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    显示全部

server/index.mjs

此文件opens in a new tab 包含服务器进程,并与 main.nropens in a new tab 中的 Noir 代码进行交互。 以下是一些有趣部分的解释。

1import { Noir } from '@noir-lang/noir_js'

noir.jsopens in a new tab 库是 JavaScript 代码和 Noir 代码之间的接口。

1const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))
2const noir = new Noir(circuit)

加载算术电路——我们在前一阶段创建的已编译 Noir 程序——并准备执行它。

1// 我们只在收到已签名请求时提供帐户信息
2const accountInformation = async signature => {
3 const fromAddress = await recoverAddress({
4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
5 signature
6 })

要提供帐户信息,我们只需要签名。 原因是,我们已经知道消息会是什么,因此也知道了消息哈希。

1const processMessage = async (message, signature) => {

处理一条消息并执行它编码的交易。

1 // 获取公钥
2 const pubKey = await recoverPublicKey({
3 hash,
4 signature
5 })

现在我们在服务器上运行 JavaScript,我们可以在服务器上而不是在客户端上检索公钥。

1 let noirResult
2 try {
3 noirResult = await noir.execute({
4 message,
5 signature: signature.slice(2,-2).match(/.{2}/g).map(x => `0x${x}`),
6 pubKeyX,
7 pubKeyY,
8 accounts: Accounts
9 })
显示全部

noir.execute 运行 Noir 程序。 这些参数等同于 Prover.tomlopens in a new tab 中提供的参数。 请注意,长值以十六进制字符串数组(["0x60", "0xA7"])的形式提供,而不是像 Viem 那样以单个十六进制值(0x60A7)的形式提供。

1 } catch (err) {
2 console.log(`Noir error: ${err}`)
3 throw Error("Invalid transaction, not processed")
4 }

如果出现错误,捕获它,然后将简化版本中继到客户端。

1 Accounts[fromAccountNumber].nonce++
2 Accounts[fromAccountNumber].balance -= amount
3 Accounts[toAccountNumber].balance += amount

应用交易。 我们已经在 Noir 代码中做过了,但在这里再做一次比从那里提取结果更容易。

1let Accounts = [
2 {
3 address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
4 balance: 5000,
5 nonce: 0,
6 },

初始的 Accounts 结构。

阶段 3 - 以太坊智能合约

  1. 停止服务器和客户端进程。

  2. 下载包含智能合约的分支,并确保您拥有所有必需的模块。

    1git checkout 03-smart-contracts
    2cd client
    3npm install
    4cd ../server
    5npm install
  3. 在单独的命令行窗口中运行 anvil

  4. 生成验证密钥和 solidity 验证器,然后将验证器代码复制到 Solidity 项目。

    1cd noir
    2bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak
    3bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol
    4cp target/Verifier.sol ../../smart-contracts/src
  5. 转到智能合约并设置环境变量以使用 anvil 区块链。

    1cd ../../smart-contracts
    2export ETH_RPC_URL=http://localhost:8545
    3ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
  6. 部署 Verifier.sol 并将地址存储在环境变量中。

    1VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`
    2echo $VERIFIER_ADDRESS
  7. 部署 ZkBank 合约。

    1ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`
    2echo $ZKBANK_ADDRESS

    0x199..67b 值是 Accounts 初始状态的 Pederson 哈希。 如果您在 server/index.mjs 中修改此初始状态,您可以运行一个交易来查看零知识证明报告的初始哈希。

  8. 运行服务器。

    1cd ../server
    2npm run start
  9. 在不同的命令行窗口中运行客户端。

    1cd client
    2npm run dev
  10. 运行一些交易。

  11. 要验证链上状态是否已更改,请重新启动服务器进程。 可以看到 ZkBank 不再接受交易,因为交易中的原始哈希值与链上存储的哈希值不同。

    这是预期的错误类型。

    1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
    2
    3> server@1.0.0 start
    4> node --experimental-json-modules index.mjs
    5
    6Listening on port 3000
    7Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason:
    8Wrong old state hash
    9
    10Contract Call:
    11 address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
    12 function: processTransaction(bytes _proof, bytes32[] _publicInputs)
    13 args: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000
    显示全部

server/index.mjs

此文件中的更改主要与创建实际证明并将其提交到链上有关。

1import { exec } from 'child_process'
2import util from 'util'
3
4const execPromise = util.promisify(exec)

我们需要使用 Barretenberg 包opens in a new tab来创建要发送到链上的实际证明。 我们可以通过运行命令行界面 (bb) 或使用 JavaScript 库 bb.jsopens in a new tab 来使用此包。 JavaScript 库比本地运行代码慢得多,所以我们在这里使用 execopens in a new tab 来使用命令行。

请注意,如果您决定使用 bb.js,您需要使用与您正在使用的 Noir 版本兼容的版本。 在撰写本文时,当前的 Noir 版本 (1.0.0-beta.11) 使用 bb.js 版本 0.87。

1const zkBankAddress = process.env.ZKBANK_ADDRESS || "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"

这里的地址是您在从干净的 anvil 开始并按照上述说明操作时获得的地址。

1const walletClient = createWalletClient({
2 chain: anvil,
3 transport: http(),
4 account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")
5})

此私钥是 anvil 中的默认预资助帐户之一。

1const generateProof = async (witness, fileID) => {

使用 bb 可执行文件生成证明。

1 const fname = `witness-${fileID}.gz`
2 await fs.writeFile(fname, witness)

将见证写入文件。

1 await execPromise(`bb prove -b ./noir/target/zkBank.json -w ${fname} -o ${fileID} --oracle_hash keccak --output_format fields`)

实际创建证明。 此步骤还会创建一个包含公共变量的文件,但我们不需要它。 我们已经从 noir.execute 获得了这些变量。

1 const proof = "0x" + JSON.parse(await fs.readFile(`./${fileID}/proof_fields.json`)).reduce((a,b) => a+b, "").replace(/0x/g, "")

证明是 Field 值的 JSON 数组,每个值都表示为十六进制值。 然而,我们需要在交易中以单个 bytes 值的形式发送它,Viem 将其表示为一个大的十六进制字符串。 在这里,我们通过连接所有值、删除所有 0x,然后在末尾添加一个来更改格式。

1 await execPromise(`rm -r ${fname} ${fileID}`)
2
3 return proof
4}

清理并返回证明。

1const processMessage = async (message, signature) => {
2 .
3 .
4 .
5
6 const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))

公共字段需要是 32 字节值的数组。 然而,由于我们需要在两个 Field 值之间划分交易哈希,它显示为一个 16 字节的值。 在这里,我们添加零,以便 Viem 将其理解为实际的 32 字节。

1 const proof = await generateProof(noirResult.witness, `${fromAddress}-${nonce}`)

每个地址只使用一次每个 nonce,这样我们就可以使用 fromAddressnonce 的组合作为见证文件和输出目录的唯一标识符。

1 try {
2 await zkBank.write.processTransaction([
3 proof, publicFields])
4 } catch (err) {
5 console.log(`Verification error: ${err}`)
6 throw Error("Can't verify the transaction onchain")
7 }
8 .
9 .
10 .
11}
显示全部

将交易发送到链上。

smart-contracts/src/ZkBank.sol

这是接收交易的链上代码。

1// SPDX-License-Identifier: MIT
2
3pragma solidity >=0.8.21;
4
5import {HonkVerifier} from "./Verifier.sol";
6
7contract ZkBank {
8 HonkVerifier immutable myVerifier;
9 bytes32 currentStateHash;
10
11 constructor(address _verifierAddress, bytes32 _initialStateHash) {
12 currentStateHash = _initialStateHash;
13 myVerifier = HonkVerifier(_verifierAddress);
14 }
显示全部

链上代码需要跟踪两个变量:验证器(由 nargo 创建的独立合约)和当前状态哈希。

1 event TransactionProcessed(
2 bytes32 indexed transactionHash,
3 bytes32 oldStateHash,
4 bytes32 newStateHash
5 );

每次状态更改时,我们都会发出一个 TransactionProcessed 事件。

1 function processTransaction(
2 bytes calldata _proof,
3 bytes32[] calldata _publicFields
4 ) public {

此函数处理交易。 它以验证器要求的格式获取证明(作为 bytes)和公共输入(作为 bytes32 数组),以最小化链上处理并因此降低燃料成本。

1 require(_publicInputs[0] == currentStateHash,
2 "Wrong old state hash");

零知识证明需要证明交易将我们当前的哈希更改为一个新的哈希。

1 myVerifier.verify(_proof, _publicFields);

调用验证器合约以验证零知识证明。 如果零知识证明错误,此步骤将回滚交易。

1 currentStateHash = _publicFields[1];
2
3 emit TransactionProcessed(
4 _publicFields[2]<<128 | _publicFields[3],
5 _publicFields[0],
6 _publicFields[1]
7 );
8 }
9}
显示全部

如果一切检查无误,则将状态哈希更新为新值,并发出一个 TransactionProcessed 事件。

中心化组件的滥用

信息安全包括三个属性:

  • 机密性,用户不能读取他们无权读取的信息。
  • 完整性,信息不能被未经授权的用户以未经授权的方式更改。
  • 可用性,授权用户可以使用系统。

在此系统中,完整性通过零知识证明提供。 可用性更难保证,而机密性则不可能,因为银行必须知道每个帐户的余额和所有交易。 无法阻止拥有信息的实体共享该信息。

使用隐身地址opens in a new tab可能可以创建一个真正机密的银行,但这超出了本文的范围。

虚假信息

服务器违反完整性的一种方式是在请求数据opens in a new tab时提供虚假信息。

为了解决这个问题,我们可以编写第二个 Noir 程序,该程序接收帐户作为私有输入,并接收请求信息的地址作为公共输入。 输出是该地址的余额和 nonce,以及帐户的哈希。

当然,此证明无法在链上验证,因为我们不希望在链上发布 nonce 和余额。 但是,它可以通过在浏览器中运行的客户端代码进行验证。

强制交易

在 L2 上确保可用性和防止审查的通常机制是强制交易opens in a new tab。 但强制交易无法与零知识证明相结合。 服务器是唯一可以验证交易的实体。

我们可以修改 smart-contracts/src/ZkBank.sol 以接受强制交易,并防止服务器在处理它们之前更改状态。 然而,这使我们面临简单的拒绝服务攻击。 如果强制交易无效,因此无法处理怎么办?

解决方案是提供一个零知识证明,证明强制交易无效。 这为服务器提供了三个选项:

  • 处理强制交易,提供零知识证明,证明其已处理并且生成了新的状态哈希。
  • 拒绝强制交易,并向合约提供零知识证明,证明交易无效(未知地址、错误的 nonce 或余额不足)。
  • 忽略强制交易。 无法强制服务器实际处理交易,但这意味着整个系统都不可用。

可用性保证金

在实际实现中,维持服务器运行可能存在某种利润动机。 我们可以通过让服务器发布可用性保证金来加强这种激励,如果在一定时期内未处理强制交易,任何人都可以销毁该保证金。

错误的 Noir 代码

通常,为了让人们信任智能合约,我们会将源代码上传到区块浏览器opens in a new tab。 然而,在零知识证明的情况下,这是不够的。

Verifier.sol 包含验证密钥,它是 Noir 程序的一个函数。 然而,该密钥并不能告诉我们 Noir 程序是什么。 要真正拥有一个可信的解决方案,您需要上传 Noir 程序(以及创建它的版本)。 否则,零知识证明可能反映了不同的程序,一个带有后门的程序。

在区块浏览器开始允许我们上传和验证 Noir 程序之前,您应该自己动手(最好上传到 IPFSopens in a new tab)。 然后,高级用户将能够下载源代码,自己编译,创建 Verifier.sol,并验证它与链上的版本完全相同。

结论

Plasma 类型的应用程序需要一个中心化组件作为信息存储。 这带来了潜在的漏洞,但作为回报,它允许我们以区块链本身无法实现的方式保护隐私。 通过零知识证明,我们可以确保完整性,并可能使运行中心化组件的任何人在经济上更有利于维护可用性。

点击此处查看我的更多作品opens in a new tab

致谢

  • Josh Crites 阅读了本文的草稿,并帮助我解决了一个棘手的 Noir 问题。

任何剩余的错误都由我负责。

页面最后更新: 2025年10月28日

本教程对你有帮助吗?