编写一个保护隐私的应用专用Plasma
本文介绍如何用零知识证明构建一个类似Plasma的隐私保护银行应用,使用以太坊保证完整性但不保证可用性。银行保存账户余额和nonce,通过Noir编写零知识证明,验证交易签名和状态转换。文章分三阶段实现:手动零知识证明、添加服务器、集成智能合约。还讨论了中心化组件的潜在滥用(如假信息、强制交易)及应对措施。最终实现离线隐私和链上验证的结合。
与 Rollup 不同,Plasma 虽依赖以太坊主网保证完整性,但不依赖它保证可用性。在本文中,我们将编写一个行为类似 Plasma 的应用,由以太坊保证完整性(防止未经授权的更改),但不保证可用性(中心化组件可能宕机,导致整个系统瘫痪)。
我们编写的应用是一个保护隐私的银行。不同地址拥有带余额的账户,它们可以向其他账户发送资金(ETH)。该银行会公布状态(账户及其余额)和交易的哈希值,但将实际余额保存在链下以保持私密性。
设计
这不是一个生产就绪的系统,而是一个教学工具。因此,它包含几个简化假设。
-
固定的账户池:有特定数量的账户,每个账户属于一个预先确定的地址。这大大简化了系统,因为在零知识证明中处理可变大小的数据结构比较困难。对于生产就绪的系统,我们可以使用 Merkle 根作为状态哈希,并为所需余额提供 Merkle 证明。
-
内存存储:在生产系统中,我们需要将所有账户余额写入磁盘,以便在重启后保留。在这里,信息丢失是可以接受的。
-
仅转账:生产系统需要一种将资产存入银行和从银行提取的方式。但本文的目的仅仅是说明概念,因此该银行仅限于转账。
零知识证明
从根本上说,零知识证明表明证明者知道某些私有数据 Dataprivate,使得某些公开数据 Datapublic 与 Dataprivate 之间存在某种关系 Relationship。验证者知道 Relationship 和 Datapublic。
为了保护隐私,我们需要将状态和交易保持私有。但为了确保完整性,我们需要公开状态的加密哈希。为了向提交交易的人证明这些交易确实发生了,我们还需要发布交易哈希。
在大多数情况下,Dataprivate 是零知识证明程序的输入,而 Datapublic 是输出。
Dataprivate 中的字段:
- Staten,旧状态
- Staten+1,新状态
- Transaction,从旧状态变为新状态的交易。此交易需要包含以下字段:
- 目标地址,接收转账的地址
- 金额,正在转账的数额
- Nonce,确保每笔交易只能被处理一次。 源地址不需要包含在交易中,因为可以通过签名恢复。
- 签名,被授权执行交易的签名。在我们的例子中,唯一被授权执行交易的地址是源地址。由于我们的零知识系统的工作方式,除了以太坊签名之外,我们还需要账户的公钥。
Datapublic 中的字段:
- Hash(Staten) 旧状态的哈希
- Hash(Staten+1) 新状态的哈希
- Hash(Transaction) 将状态从 Staten 变为 Staten+1 的交易的哈希
关系检查几个条件:
- 公开的哈希确实是私有字段的正确哈希。
- 将交易应用于旧状态,结果得到新状态。
- 签名来自交易的源地址。
由于加密哈希函数的性质,证明这些条件就足以保证完整性。
数据结构
主要数据结构是服务器持有的状态。对于每个账户,服务器跟踪账户余额和一个 nonce,用于防止重放攻击。
组件
该系统需要两个组件:
- 服务器,它接收交易、处理交易,并将哈希值连同零知识证明发布到链上。
- 智能合约,它存储哈希值并验证零知识证明,以确保状态转换是合法的。
数据和控制流
以下是从一个账户转账到另一个账户时各组件之间的通信方式。
-
Web 浏览器提交一个签名交易,请求从签名者的账户转账到另一个账户。
-
服务器验证交易是否有效:
- 签名者在银行中有一个余额足够的账户。
- 收款者在银行中有一个账户。
-
服务器通过从签名者余额中减去转账金额并加到收款者余额中,计算出新状态。
-
服务器计算零知识证明,证明状态变化是有效的。
-
服务器向以太坊提交一笔交易,其中包含:
- 新状态哈希
- 交易哈希(以便交易发送者知道它已被处理)
- 证明转换到新状态有效的零知识证明
-
智能合约验证零知识证明。
-
如果零知识证明通过检查,智能合约执行以下操作:
- 将当前状态哈希更新为新状态哈希
- 发出包含新状态哈希和交易哈希的日志条目
工具
对于客户端代码,我们将使用 Vite、React、Viem 和 Wagmi。这些是行业标准工具;如果你不熟悉它们,可以参考本教程。
服务器的大部分代码使用 Node 以 JavaScript 编写。零知识部分使用 Noir 编写。我们需要版本 1.0.0-beta.10,因此,按照说明安装 Noir 后,运行:
noirup -v 1.0.0-beta.10
我们使用的区块链是 anvil,它是 Foundry 的一部分,一个本地的测试区块链。
实现
由于这是一个复杂的系统,我们将分阶段实现。
阶段 1 - 手动零知识
在第一阶段,我们将在浏览器中签署一笔交易,然后手动将信息提供给零知识证明。零知识代码期望在 server/noir/Prover.toml 中获取这些信息(记录在这里)。
要查看它的运作:
-
下载阶段 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)不接受直接从磁盘提供的文件。
-
打开一个带有钱包的浏览器。
-
在钱包中,输入一个新的助记词。请注意,这将删除你现有的助记词,所以请确保你有备份。
助记词是
test test test test test test test test test test test junk,即anvil的默认测试助记词。 -
浏览到客户端代码。
-
连接到钱包,选择你的目标账户和金额。
-
点击 Sign 并签署交易。
-
在 Prover.toml 标题下方,你会找到文本。用该文本替换
server/noir/Prover.toml。 -
执行零知识证明。
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) -
将最后两个值与你在 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> 表示该位数的无符号整数。支持的类型只有 u8、u16、u32、u64 和 u128。
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.nonce 是 u32 值,但要将其加到 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();
}
将 hash1 和 hash2 指定为可变变量,并逐字节将哈希值写入其中。
(
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 - 添加服务器
在第二阶段,我们添加一个服务器,负责从浏览器接收并实现转账交易。
要查看它的运作:
-
停止 Vite(如果正在运行)。
-
下载包含服务器的分支,并确保你拥有所有必要的模块。
git checkout 02-add-server cd client npm install cd ../server npm install无需编译 Noir 代码,它与你在阶段 1 中使用的代码相同。
-
启动服务器。
npm run start -
在另一个命令行窗口中,运行 Vite 以提供浏览器代码。
cd client npm run dev -
浏览到 http://localhost:5173 的客户端代码。
-
在发起交易之前,你需要知道 nonce 以及你可以发送的金额。要获取这些信息,请点击 Update account data 并签署消息。
我们面临一个困境。一方面,我们不希望签署一条可能被重复使用的消息(重放攻击),这正是我们首先需要一个 nonce 的原因。但是,我们还没有 nonce。解决方案是选择一个只能使用一次且双方都已经拥有的 nonce,例如当前时间。
这样做的问题在于时间可能不是完全同步的。因此,我们改为签署一个每分钟变化的值。这意味着我们面临重放攻击的窗口最多为一分钟。考虑到在生产环境中,签名请求将受到 TLS 保护,并且隧道另一端——服务器——已经可以披露余额和 nonce(它必须知道这些才能工作),这个风险是可以接受的。
-
一旦浏览器获取到余额和 nonce,它会显示转账表单。选择目标地址和金额,然后点击 Transfer。签署此请求。
-
要查看转账,可以点击 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 - 以太坊智能合约
-
停止服务器和客户端进程。
-
下载包含智能合约的分支,并确保你拥有所有必要的模块。
git checkout 03-smart-contracts cd client npm install cd ../server npm install -
在另一个命令行窗口中运行
anvil。 -
生成验证密钥和 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 -
进入智能合约目录,设置环境变量以使用
anvil区块链。cd ../../smart-contracts export ETH_RPC_URL=http://localhost:8545 ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -
部署
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 -
部署
ZkBank合约。ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'` echo $ZKBANK_ADDRESS0x199..67b值是Accounts初始状态的 Pedersen 哈希。如果你在server/index.mjs中修改了这个初始状态,你可以运行一笔交易来查看零知识证明报告的初始哈希。 -
运行服务器。
cd ../server npm run start -
在另一个命令行窗口中运行客户端。
cd client npm run dev -
运行一些交易。
-
为了验证链上的状态发生了变化,重启服务器进程。可以看到
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 只使用一次,因此我们可以使用 fromAddress 和 nonce 的组合作为 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 问题。
任何遗留的错误由我负责。
本教程对你有帮助吗?
- 原文链接: ethereum.org/developers/...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

