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

Ethereum.org 发布于 2025-10-15 阅读 87

本文介绍如何用零知识证明构建一个类似Plasma的隐私保护银行应用,使用以太坊保证完整性但不保证可用性。银行保存账户余额和nonce,通过Noir编写零知识证明,验证交易签名和状态转换。文章分三阶段实现:手动零知识证明、添加服务器、集成智能合约。还讨论了中心化组件的潜在滥用(如假信息、强制交易)及应对措施。最终实现离线隐私和链上验证的结合。

Rollup 不同,Plasma 虽依赖以太坊主网保证完整性,但不依赖它保证可用性。在本文中,我们将编写一个行为类似 Plasma 的应用,由以太坊保证完整性(防止未经授权的更改),但不保证可用性(中心化组件可能宕机,导致整个系统瘫痪)。

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

设计

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

  • 固定的账户池:有特定数量的账户,每个账户属于一个预先确定的地址。这大大简化了系统,因为在零知识证明中处理可变大小的数据结构比较困难。对于生产就绪的系统,我们可以使用 Merkle 根作为状态哈希,并为所需余额提供 Merkle 证明。

  • 内存存储:在生产系统中,我们需要将所有账户余额写入磁盘,以便在重启后保留。在这里,信息丢失是可以接受的。

  • 仅转账:生产系统需要一种将资产存入银行和从银行提取的方式。但本文的目的仅仅是说明概念,因此该银行仅限于转账。

零知识证明

从根本上说,零知识证明表明证明者知道某些私有数据 Dataprivate,使得某些公开数据 DatapublicDataprivate 之间存在某种关系 Relationship。验证者知道 RelationshipDatapublic

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

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

Dataprivate 中的字段:

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

Datapublic 中的字段:

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

关系检查几个条件:

  • 公开的哈希确实是私有字段的正确哈希。
  • 将交易应用于旧状态,结果得到新状态。
  • 签名来自交易的源地址。

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

数据结构

主要数据结构是服务器持有的状态。对于每个账户,服务器跟踪账户余额和一个 nonce,用于防止重放攻击

组件

该系统需要两个组件:

  • 服务器,它接收交易、处理交易,并将哈希值连同零知识证明发布到链上。
  • 智能合约,它存储哈希值并验证零知识证明,以确保状态转换是合法的。

数据和控制流

以下是从一个账户转账到另一个账户时各组件之间的通信方式。

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

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

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

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

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

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

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

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

工具

对于客户端代码,我们将使用 ViteReactViemWagmi。这些是行业标准工具;如果你不熟悉它们,可以参考本教程

服务器的大部分代码使用 Node 以 JavaScript 编写。零知识部分使用 Noir 编写。我们需要版本 1.0.0-beta.10,因此,按照说明安装 Noir 后,运行:

noirup -v 1.0.0-beta.10

我们使用的区块链是 anvil,它是 Foundry 的一部分,一个本地的测试区块链。

实现

由于这是一个复杂的系统,我们将分阶段实现。

阶段 1 - 手动零知识

在第一阶段,我们将在浏览器中签署一笔交易,然后手动将信息提供给零知识证明。零知识代码期望在 server/noir/Prover.toml 中获取这些信息(记录在这里)。

要查看它的运作:

  1. 确保你已经安装了 NodeNoir。最好在 UNIX 系统(如 macOS、Linux 或 WSL)上安装。

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

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

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

  3. 打开一个带有钱包的浏览器。

  4. 在钱包中,输入一个新的助记词。请注意,这将删除你现有的助记词,所以请确保你有备份

    助记词是 test test test test test test test test test test test junk,即 anvil 的默认测试助记词。

  5. 浏览到客户端代码

  6. 连接到钱包,选择你的目标账户和金额。

  7. 点击 Sign 并签署交易。

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

  9. 执行零知识证明。

    cd ../server/noir
    nargo execute
    

    输出应类似于:

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

server/noir/Prover.toml

该文件展示了 Noir 期望的信息格式。

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

消息是文本格式,这便于用户理解(签署时需要)和 Noir 代码解析。金额以 finney 为单位,以便一方面支持小额转账,另一方面易于阅读。最后一个数字是 nonce

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

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

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

[[accounts]]
address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
balance=100_000
nonce=0

[[accounts]]
address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
balance=100_000
nonce=0

这是指定结构数组的方式。对于每个条目,我们指定地址、余额(以 milliether 为单位,也称为 finney)以及下一个 nonce 值。

client/src/Transfer.tsx

该文件实现了客户端处理,并生成了 server/noir/Prover.toml 文件(包含零知识参数的那个)。

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

export default attrs =>  {

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

  const accounts = [\
    "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",\
    "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",\
    "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",\
    "0x90F79bf6EB2c4f870365E785982E1f101E93b906",\
    "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",\
  ]

这些是账户地址,由 test ... test junk 助记词创建。如果你想使用自己的地址,只需修改这个定义。

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

这些 Wagmi Hook 让我们能够访问 viem 库和钱包。

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

这是消息,用空格填充。每次 useState 变量之一发生变化时,组件会重新绘制,message 也会更新。

  const sign = async () => {

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

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

请求钱包签署消息

    const hash = hashMessage(message)

获取消息哈希值。提供这个值给用户有助于调试(Noir 代码)。

    const pubKey = await recoverPublicKey({
        hash,
        signature
    })

获取公钥。这是 Noir ecrecover 函数所需要的。

    setSignature(signature)
    setHash(hash)
    setPubKey(pubKey)

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

    let proverToml = `

Prover.toml 的文本。

message="${message}"

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

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

然而,Noir 期望将这些信息作为两个字节数组提供,一个用于 x,一个用于 y。在客户端解析比在零知识证明中解析更容易。

请注意,这通常是零知识领域的良好实践。零知识证明内部的代码成本很高,因此任何可以在零知识证明外部进行的处理都应该在外部进行。

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

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

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

提供账户。

    setProverToml(proverToml)
  }

  return (
    <>
        <h2>Transfer</h2>

这是组件的 HTML(更准确地说是 JSX)格式。

server/noir/src/main.nr

该文件是实际的零知识代码。

use std::hash::pedersen_hash;

Pedersen 哈希Noir 标准库提供。零知识证明通常使用这种哈希函数。它在算术电路内部计算起来比标准哈希函数容易得多。

use keccak256::keccak256;
use dep::ecrecover;

这两个函数是外部库,在 Nargo.toml 中定义。它们正如其名,一个计算 keccak256 哈希的函数,以及一个验证以太坊签名并恢复签名者以太坊地址的函数。

global ACCOUNT_NUMBER : u32 = 5;

Noir 受 Rust 启发。默认情况下,变量是常量。这是我们定义全局配置常量的方式。具体来说,ACCOUNT_NUMBER 是我们存储的账户数量。

数据类型命名为 u<number> 表示该位数的无符号整数。支持的类型只有 u8u16u32u64u128

global FLAT_ACCOUNT_FIELDS : u32 = 2;

该变量用于账户的 Pedersen 哈希,具体原因下文说明。

global MESSAGE_LENGTH : u32 = 100;

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

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

EIP-191 签名需要一个缓冲区,其中包含 26 字节的前缀,后跟 ASCII 格式的消息长度,最后是消息本身。

struct Account {
    balance: u128,
    address: Field,
    nonce: u32,
}

我们存储的账户信息。Field 是一个数字,通常最多 253 位,可以直接在实现零知识证明的算术电路中使用。这里我们使用 Field 存储 160 位的以太坊地址。

struct TransferTxn {
    from: Field,
    to: Field,
    amount: u128,
    nonce: u32
}

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

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

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

    let flat = [\
        account.address,\
        ((account.balance << 32) + account.nonce.into()).into(),\
    ];

数组的第一个值是账户地址。第二个值同时包含余额和 nonce。.into() 调用将数字转换为所需的数据类型。account.nonceu32 值,但要将其加到 account.balance << 32(一个 u128 值)上,它必须是 u128 类型。这是第一个 .into()。第二个 .into()u128 结果转换为 Field,以便放入数组。

    flat
}

在 Noir 中,函数只能在末尾返回一个值(没有提前返回)。要指定返回值,只需在函数结束大括号之前计算它。

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

该函数将账户数组转换为 Field 数组,可用作 Pedersen 哈希的输入。

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

这是指定可变变量的方式,即不是常量。Noir 中的变量必须始终有一个值,因此我们将其初始化为全零。

    for i in 0..ACCOUNT_NUMBER {

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

        let fields = flatten_account(accounts[i]);
        for j in 0..FLAT_ACCOUNT_FIELDS {
            flat[i*FLAT_ACCOUNT_FIELDS + j] = fields[j];
        }
    }

    flat
}

fn hash_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> Field {
    pedersen_hash(flatten_accounts(accounts))
}

最终,我们得到了哈希账户数组的函数。

fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {
    let mut account : u32 = ACCOUNT_NUMBER;

    for i in 0..ACCOUNT_NUMBER {
        if accounts[i].address == address {
            account = i;
        }
    }

该函数查找具有特定地址的账户。这个函数在标准代码中效率极低,因为它会遍历所有账户,即使已经找到了地址。

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

if 语句也类似。上面循环中的 if 语句被翻译成以下数学语句:

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

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

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

    account
}

assert 函数会在断言为假时导致零知识证明崩溃。在这种情况下,如果找不到具有相关地址的账户,就会崩溃。为了报告地址,我们使用格式化字符串

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

该函数应用一笔转账交易,并返回新的账户数组。

    let from = find_account(accounts, txn.from);
    let to = find_account(accounts, txn.to);

    let (txnFrom, txnAmount, txnNonce, accountNonce) =
        (txn.from, txn.amount, txn.nonce, accounts[from].nonce);

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

    assert (accounts[from].balance >= txn.amount,
        f"{txnFrom} does not have {txnAmount} finney");

    assert (accounts[from].nonce == txn.nonce,
        f"Transaction has nonce {txnNonce}, but the account is expected to use {accountNonce}");

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

    let mut newAccounts = accounts;

    newAccounts[from].balance -= txn.amount;
    newAccounts[from].nonce += 1;
    newAccounts[to].balance += txn.amount;

    newAccounts
}

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

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

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

{
    let mut result : Field = 0;

    for i in 7..47 {

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

        result *= 0x10;
        if messageBytes[i] >= 48 & messageBytes[i] <= 57 {    // 0-9
            result += (messageBytes[i]-48).into();
        }
        if messageBytes[i] >= 65 & messageBytes[i] <= 70 {    // A-F
            result += (messageBytes[i]-65+10).into()
        }
        if messageBytes[i] >= 97 & messageBytes[i] <= 102 {   // a-f
            result += (messageBytes[i]-97+10).into()
        }
    }

    result
}

fn readAmountAndNonce(messageBytes: [u8; MESSAGE_LENGTH]) -> (u128, u32)

从消息中读取金额和 nonce。

{
    let mut amount : u128 = 0;
    let mut nonce: u32 = 0;
    let mut stillReadingAmount: bool = true;
    let mut lookingForNonce: bool = false;
    let mut stillReadingNonce: bool = false;

在消息中,地址之后的第一个数字是要转账的 finney(即千分之一 ETH)数量。第二个数字是 nonce。两者之间的任何文本都会被忽略。

    for i in 48..MESSAGE_LENGTH {
        if messageBytes[i] >= 48 & messageBytes[i] <= 57 {    // 0-9
            let digit = (messageBytes[i]-48);

            if stillReadingAmount {
                amount = amount*10 + digit.into();
            }

            if lookingForNonce {    // 我们刚找到它
                stillReadingNonce = true;
                lookingForNonce = false;
            }

            if stillReadingNonce {
                nonce = nonce*10 + digit.into();
            }
        } else {
            if stillReadingAmount {
                stillReadingAmount = false;
                lookingForNonce = true;
            }
            if stillReadingNonce {
                stillReadingNonce = false;
            }
        }
    }

    (amount, nonce)
}

返回一个元组是 Noir 从函数返回多个值的方式。

fn readTransferTxn(message: str<MESSAGE_LENGTH>) -> TransferTxn
{
    let mut txn: TransferTxn = TransferTxn { from: 0, to: 0, amount:0, nonce:0 };
    let messageBytes = message.as_bytes();

    txn.to = readAddress(messageBytes);
    let (amount, nonce) = readAmountAndNonce(messageBytes);
    txn.amount = amount;
    txn.nonce = nonce;

    txn
}

该函数将消息转换为字节,然后将数量转换为 TransferTxn

// The equivalent to Viem's hashMessage
// https://viem.sh/docs/utilities/hashMessage#hashmessage
fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {

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

    // ASCII prefix
    let prefix_bytes = [\
        0x19, // \x19\
        0x45, // 'E'\
        0x74, // 't'\
        0x68, // 'h'\
        0x65, // 'e'\
        0x72, // 'r'\
        0x65, // 'e'\
        0x75, // 'u'\
        0x6D, // 'm'\
        0x20, // ' '\
        0x53, // 'S'\
        0x69, // 'i'\
        0x67, // 'g'\
        0x6E, // 'n'\
        0x65, // 'e'\
        0x64, // 'd'\
        0x20, // ' '\
        0x4D, // 'M'\
        0x65, // 'e'\
        0x73, // 's'\
        0x73, // 's'\
        0x61, // 'a'\
        0x67, // 'g'\
        0x65, // 'e'\
        0x3A, // ':'\
        0x0A  // '\n'\
    ];

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

    let mut buffer: [u8; HASH_BUFFER_SIZE] = [0u8; HASH_BUFFER_SIZE];
    for i in 0..26 {
        buffer[i] = prefix_bytes[i];
    }

    let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();

    if MESSAGE_LENGTH <= 9 {
        for i in 0..1 {
            buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
        }

        for i in 0..MESSAGE_LENGTH {
            buffer[i+26+1] = messageBytes[i];
        }
    }

    if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {
        for i in 0..2 {
            buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
        }

        for i in 0..MESSAGE_LENGTH {
            buffer[i+26+2] = messageBytes[i];
        }
    }

    if MESSAGE_LENGTH >= 100 {
        for i in 0..3 {
            buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
        }

        for i in 0..MESSAGE_LENGTH {
            buffer[i+26+3] = messageBytes[i];
        }
    }

    assert(MESSAGE_LENGTH < 1000, "Messages whose length is over three digits are not supported");

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

    keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
}

使用以太坊标准的 keccak256 函数。

fn signatureToAddressAndHash(
        message: str<MESSAGE_LENGTH>,
        pubKeyX: [u8; 32],
        pubKeyY: [u8; 32],
        signature: [u8; 64]
    ) -> (Field, Field, Field)   // address, first 16 bytes of hash, last 16 bytes of hash
{

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

我们需要使用两个 Field 值,因为域计算是一个大数进行的,但这个数通常小于 256 位(否则在 EVM 中执行这些计算会很困难)。

    let hash = hashMessage(message);

    let mut (hash1, hash2) = (0,0);

    for i in 0..16 {
        hash1 = hash1*256 + hash[31-i].into();
        hash2 = hash2*256 + hash[15-i].into();
    }

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

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

这类似于 Solidity 的 ecrecover,有两个重要区别:

  • 如果签名无效,调用会失败并触发 assert,程序终止。
  • 虽然可以从签名和哈希中恢复公钥,但这是可以在外部进行的处理,因此不值得在零知识证明内部进行。如果有人试图在这里欺骗我们,签名验证将会失败。
        hash1,
        hash2
    )
}

fn main(
        accounts: [Account; ACCOUNT_NUMBER],
        message: str<MESSAGE_LENGTH>,
        pubKeyX: [u8; 32],
        pubKeyY: [u8; 32],
        signature: [u8; 64],
    ) -> pub (
        Field,  // 旧账户数组的哈希
        Field,  // 新账户数组的哈希
        Field,  // 消息哈希的前 16 字节
        Field,  // 消息哈希的后 16 字节
    )

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

{
    let mut txn = readTransferTxn(message);

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

    let (fromAddress, txnHash1, txnHash2) = signatureToAddressAndHash(
        message,
        pubKeyX,
        pubKeyY,
        signature);

    txn.from = fromAddress;

    let newAccounts = apply_transfer_txn(accounts, txn);

    (
        hash_accounts(accounts),
        hash_accounts(newAccounts),
        txnHash1,
        txnHash2
    )
}

阶段 2 - 添加服务器

在第二阶段,我们添加一个服务器,负责从浏览器接收并实现转账交易。

要查看它的运作:

  1. 停止 Vite(如果正在运行)。

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

    git checkout 02-add-server
    cd client
    npm install
    cd ../server
    npm install
    

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

  3. 启动服务器。

    npm run start
    
  4. 在另一个命令行窗口中,运行 Vite 以提供浏览器代码。

    cd client
    npm run dev
    
  5. 浏览到 http://localhost:5173 的客户端代码。

  6. 在发起交易之前,你需要知道 nonce 以及你可以发送的金额。要获取这些信息,请点击 Update account data 并签署消息。

    我们面临一个困境。一方面,我们不希望签署一条可能被重复使用的消息(重放攻击),这正是我们首先需要一个 nonce 的原因。但是,我们还没有 nonce。解决方案是选择一个只能使用一次且双方都已经拥有的 nonce,例如当前时间。

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

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

  8. 要查看转账,可以点击 Update account data,或者查看运行服务器的窗口。服务器在每次状态变化时都会记录状态。

    ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
    
    > server@1.0.0 start
    > node --experimental-json-modules index.mjs
    
    Listening on port 3000
    Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed
    New state:
    0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 64000 (1)
    0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 100000 (0)
    0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)
    0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 processed
    New state:
    0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 56800 (2)
    0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)
    0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)
    0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 processed
    New state:
    0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 53800 (3)
    0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)
    0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 139000 (0)
    0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    

server/index.mjs

该文件包含服务器进程,并与 main.nr 中的 Noir 代码交互。以下是对有趣部分的解释。

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

noir.js 库是 JavaScript 代码与 Noir 代码之间的接口。

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

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

// 我们仅在响应签名请求时提供账户信息
const accountInformation = async signature => {
    const fromAddress = await recoverAddress({
        hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
        signature
    })

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

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

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

    // 获取公钥
    const pubKey = await recoverPublicKey({
        hash,
        signature
    })

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

    let noirResult
    try {
        noirResult = await noir.execute({
            message,
            signature: signature.slice(2,-2).match(/.{2}/g).map(x => `0x${x}`),
            pubKeyX,
            pubKeyY,
            accounts: Accounts
        })

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

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

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

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

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

let Accounts = [\
    {\
        address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",\
        balance: 5000,\
        nonce: 0,\
    },\

初始的 Accounts 结构。

阶段 3 - 以太坊智能合约

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

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

    git checkout 03-smart-contracts
    cd client
    npm install
    cd ../server
    npm install
    
  3. 在另一个命令行窗口中运行 anvil

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

    cd noir
    bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak
    bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol
    cp target/Verifier.sol ../../smart-contracts/src
    
  5. 进入智能合约目录,设置环境变量以使用 anvil 区块链。

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

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

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

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

  8. 运行服务器。

    cd ../server
    npm run start
    
  9. 在另一个命令行窗口中运行客户端。

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

  11. 为了验证链上的状态发生了变化,重启服务器进程。可以看到 ZkBank 不再接受交易,因为交易中的原始哈希值与链上存储的哈希值不同。

    这是预期的错误类型。

    ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
    
    > server@1.0.0 start
    > node --experimental-json-modules index.mjs
    
    Listening on port 3000
    Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason:
    Wrong old state hash
    
    Contract Call:
        address:   0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
        function:  processTransaction(bytes _proof, bytes32[] _publicInputs)
        args:                        (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000\
    

server/index.mjs

该文件中的更改主要涉及创建实际证明并将其提交到链上。

import { exec } from 'child_process'
import util from 'util'

const execPromise = util.promisify(exec)

我们需要使用 Barretenberg 包 来创建要提交到链上的实际证明。我们可以通过运行命令行界面 (bb) 或使用 JavaScript 库 bb.js 来使用这个包。JavaScript 库比原生运行代码慢得多,因此我们在这里使用 exec 来使用命令行。

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

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

这里的地址是当你从干净的 anvil 开始并按照上面的说明操作时得到的地址。

const walletClient = createWalletClient({
    chain: anvil,
    transport: http(),
    account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")
})

这个私钥是 anvil 中默认的预资助账户之一。

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

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

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

将 witness 写入文件。

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

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

    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,然后在末尾添加一个来更改格式。

    await execPromise(`rm -r ${fname} ${fileID}`)

    return proof
}

清理并返回证明。

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

    const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))

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

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

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

    try {
        await zkBank.write.processTransaction([\
            proof, publicFields])
    } catch (err) {
        console.log(`Verification error: ${err}`)
        throw Error("Can't verify the transaction onchain")
    }
    .
    .
    .
}

将交易发送到链上。

smart-contracts/src/ZkBank.sol

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

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.21;

import {HonkVerifier} from "./Verifier.sol";

contract ZkBank {
    HonkVerifier immutable myVerifier;
    bytes32 currentStateHash;

    constructor(address _verifierAddress, bytes32 _initialStateHash) {
        currentStateHash = _initialStateHash;
        myVerifier = HonkVerifier(_verifierAddress);
    }

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

    event TransactionProcessed(
        bytes32 indexed transactionHash,
        bytes32 oldStateHash,
        bytes32 newStateHash
    );

每次状态发生变化时,我们都会发出一个 TransactionProcessed 事件。

    function processTransaction(
        bytes calldata _proof,
        bytes32[] calldata _publicFields
    ) public {

该函数处理交易。它接收证明(作为 bytes)和公共输入(作为 bytes32 数组),格式与验证器要求的格式一致(以最小化链上处理并因此降低 gas 成本)。

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

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

        myVerifier.verify(_proof, _publicFields);

调用验证器合约来验证零知识证明。如果零知识证明错误,这一步会回滚交易。

        currentStateHash = _publicFields[1];

        emit TransactionProcessed(
            _publicFields[2]<<128 | _publicFields[3],
            _publicFields[0],
            _publicFields[1]
        );
    }
}

如果一切检查通过,将状态哈希更新为新值,并发出 TransactionProcessed 事件。

中心化组件的滥用

信息安全包括三个属性:

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

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

或许可以使用隐形地址创建一个真正保密的银行,但这超出了本文的范围。

虚假信息

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

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

当然,这个证明不能在链上验证,因为我们不想将 nonce 和余额发布到链上。但是,它可以在浏览器中运行的客户端代码中验证。

强制交易

确保 L2 可用性和防止审查的常用机制是强制交易。但强制交易与零知识证明不兼容。服务器是唯一能够验证交易的实体。

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

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

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

可用性保证金

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

糟糕的 Noir 代码

通常,为了让人们信任一个智能合约,我们会将源代码上传到区块浏览器。然而,对于零知识证明,这还不够。

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

在区块浏览器开始允许我们上传和验证 Noir 程序之前,你应该自己上传(最好上传到 IPFS)。然后,高级用户将能够下载源代码,自己编译,创建 Verifier.sol,并验证它是否与链上的相同。

结论

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

在此处查看更多我的工作

致谢

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

任何遗留的错误由我负责。

qbzzt

wackerow

本教程对你有帮助吗?

  • 原文链接: ethereum.org/developers/...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论