Nhảy đến nội dung chính

Viết một plasma dành riêng cho ứng dụng nhằm bảo vệ quyền riêng tư

không tiết lộ thông tin
máy chủ
ngoài chuỗi
quyền riêng tư
Nâng cao
Ori Pomerantz
15 tháng 10, 2025
41 số phút đọc

Giới thiệu

Trái ngược với rollups, plasmas sử dụng mạng chính Ethereum để đảm bảo tính toàn vẹn, nhưng không đảm bảo tính khả dụng. Trong bài viết này, chúng tôi viết một ứng dụng hoạt động giống như một plasma, với Ethereum đảm bảo tính toàn vẹn (không có thay đổi trái phép) nhưng không đảm bảo tính khả dụng (một thành phần tập trung có thể ngừng hoạt động và vô hiệu hóa toàn bộ hệ thống).

Ứng dụng mà chúng tôi viết ở đây là một ngân hàng bảo vệ quyền riêng tư. Các địa chỉ khác nhau có các tài khoản với số dư, và họ có thể gửi tiền (ETH) đến các tài khoản khác. Ngân hàng đăng các giá trị băm của trạng thái (tài khoản và số dư của chúng) và các giao dịch, nhưng giữ số dư thực tế ngoài chuỗi nơi chúng có thể được giữ riêng tư.

Thiết kế

Đây không phải là một hệ thống sẵn sàng cho sản xuất, mà là một công cụ giảng dạy. Vì vậy, nó được viết với một số giả định đơn giản hóa.

  • Nhóm tài khoản cố định. Có một số lượng tài khoản cụ thể, và mỗi tài khoản thuộc về một địa chỉ được xác định trước. Điều này tạo ra một hệ thống đơn giản hơn nhiều vì rất khó xử lý các cấu trúc dữ liệu có kích thước thay đổi trong các bằng chứng không tiết lộ thông tin. Đối với một hệ thống sẵn sàng cho sản xuất, chúng ta có thể sử dụng gốc Merkle làm giá trị băm trạng thái và cung cấp bằng chứng Merkle cho các số dư được yêu cầu.

  • Lưu trữ trong bộ nhớ. Trên một hệ thống sản xuất, chúng ta cần ghi tất cả số dư tài khoản vào đĩa để bảo toàn chúng trong trường hợp khởi động lại. Ở đây, không sao nếu thông tin bị mất.

  • Chỉ chuyển khoản. Một hệ thống sản xuất sẽ yêu cầu một cách để gửi tài sản vào ngân hàng và rút chúng ra. Nhưng mục đích ở đây chỉ là để minh họa khái niệm, vì vậy ngân hàng này chỉ giới hạn ở các giao dịch chuyển khoản.

Bằng chứng không tiết lộ thông tin

Ở cấp độ cơ bản, một bằng chứng không tiết lộ thông tin cho thấy người chứng minh biết một số dữ liệu, Dữ liệuriêng tư sao cho có một mối quan hệ Mối quan hệ giữa một số dữ liệu công khai, Dữ liệucông khai, và Dữ liệuriêng tư. Người xác minh biết Mối quan hệDữ liệucông khai.

Để bảo vệ quyền riêng tư, chúng ta cần các trạng thái và giao dịch phải là riêng tư. Nhưng để đảm bảo tính toàn vẹn, chúng ta cần hàm băm mật mã (opens in a new tab) của các trạng thái phải được công khai. Để chứng minh cho những người gửi giao dịch rằng những giao dịch đó đã thực sự xảy ra, chúng ta cũng cần đăng các giá trị băm của giao dịch.

Trong hầu hết các trường hợp, Dữ liệuriêng tư là đầu vào của chương trình bằng chứng không tiết lộ thông tin, và Dữ liệucông khai là đầu ra.

Các trường này trong Dữ liệuriêng tư:

  • Trạng tháin, trạng thái cũ
  • Trạng tháin+1, trạng thái mới
  • Giao dịch, một giao dịch thay đổi từ trạng thái cũ sang trạng thái mới. Giao dịch này cần bao gồm các trường sau:
    • Địa chỉ đích nhận chuyển khoản
    • Số tiền được chuyển
    • Nonce để đảm bảo mỗi giao dịch chỉ có thể được xử lý một lần. Địa chỉ nguồn không cần phải có trong giao dịch, vì nó có thể được phục hồi từ chữ ký.
  • Chữ ký, một chữ ký được ủy quyền để thực hiện giao dịch. Trong trường hợp của chúng ta, địa chỉ duy nhất được ủy quyền để thực hiện giao dịch là địa chỉ nguồn. Bởi vì hệ thống không tiết lộ thông tin của chúng ta hoạt động theo cách của nó, chúng ta cũng cần khóa công khai của tài khoản, ngoài chữ ký Ethereum.

Đây là các trường trong Dữ liệucông khai:

  • Băm(Trạng tháin) giá trị băm của trạng thái cũ
  • Băm(Trạng tháin+1) giá trị băm của trạng thái mới
  • Băm(Giao dịch) giá trị băm của giao dịch thay đổi trạng thái từ Trạng tháin thành Trạng tháin+1.

Mối quan hệ kiểm tra một số điều kiện:

  • Các giá trị băm công khai thực sự là các giá trị băm chính xác cho các trường riêng tư.
  • Giao dịch, khi được áp dụng cho trạng thái cũ, sẽ tạo ra trạng thái mới.
  • Chữ ký đến từ địa chỉ nguồn của giao dịch.

Do các thuộc tính của hàm băm mật mã, việc chứng minh các điều kiện này là đủ để đảm bảo tính toàn vẹn.

Các cấu trúc dữ liệu

Cấu trúc dữ liệu chính là trạng thái được máy chủ nắm giữ. Đối với mỗi tài khoản, máy chủ theo dõi số dư tài khoản và một nonce (opens in a new tab), được sử dụng để ngăn chặn các cuộc tấn công phát lại (opens in a new tab).

Các thành phần

Hệ thống này yêu cầu hai thành phần:

  • Máy chủ nhận giao dịch, xử lý chúng và đăng các giá trị băm lên chuỗi cùng với các bằng chứng không tiết lộ thông tin.
  • Một hợp đồng thông minh lưu trữ các giá trị băm và xác minh các bằng chứng không tiết lộ thông tin để đảm bảo các chuyển đổi trạng thái là hợp lệ.

Luồng dữ liệu và điều khiển

Đây là các cách mà các thành phần khác nhau giao tiếp để chuyển tiền từ một tài khoản sang tài khoản khác.

  1. Một trình duyệt web gửi một giao dịch đã ký yêu cầu chuyển khoản từ tài khoản của người ký đến một tài khoản khác.

  2. Máy chủ xác minh rằng giao dịch là hợp lệ:

    • Người ký có một tài khoản trong ngân hàng với số dư đủ.
    • Người nhận có một tài khoản trong ngân hàng.
  3. Máy chủ tính toán trạng thái mới bằng cách trừ số tiền đã chuyển từ số dư của người ký và cộng vào số dư của người nhận.

  4. Máy chủ tính toán một bằng chứng không tiết lộ thông tin rằng việc thay đổi trạng thái là hợp lệ.

  5. Máy chủ gửi một giao dịch đến Ethereum bao gồm:

    • Giá trị băm trạng thái mới
    • Giá trị băm giao dịch (để người gửi giao dịch có thể biết nó đã được xử lý)
    • Bằng chứng không tiết lộ thông tin chứng minh việc chuyển đổi sang trạng thái mới là hợp lệ
  6. Hợp đồng thông minh xác minh bằng chứng không tiết lộ thông tin.

  7. Nếu bằng chứng không tiết lộ thông tin được kiểm tra thành công, hợp đồng thông minh sẽ thực hiện các hành động sau:

    • Cập nhật giá trị băm trạng thái hiện tại thành giá trị băm trạng thái mới
    • Phát ra một mục nhật ký với giá trị băm trạng thái mới và giá trị băm giao dịch

Công cụ

Đối với mã phía máy khách, chúng ta sẽ sử dụng Vite (opens in a new tab), React (opens in a new tab), Viem (opens in a new tab)Wagmi (opens in a new tab). Đây là những công cụ tiêu chuẩn ngành; nếu bạn không quen thuộc với chúng, bạn có thể sử dụng hướng dẫn này.

Phần lớn máy chủ được viết bằng JavaScript sử dụng Node (opens in a new tab). Phần không tiết lộ thông tin được viết bằng Noir (opens in a new tab). Chúng ta cần phiên bản 1.0.0-beta.10, vì vậy sau khi bạn cài đặt Noir theo hướng dẫn (opens in a new tab), hãy chạy:

1noirup -v 1.0.0-beta.10

Chuỗi khối chúng ta sử dụng là anvil, một chuỗi khối thử nghiệm cục bộ là một phần của Foundry (opens in a new tab).

Triển khai

Vì đây là một hệ thống phức tạp, chúng tôi sẽ triển khai nó theo từng giai đoạn.

Giai đoạn 1 - Không tiết lộ thông tin thủ công

Đối với giai đoạn đầu tiên, chúng tôi sẽ ký một giao dịch trong trình duyệt và sau đó cung cấp thông tin theo cách thủ công cho bằng chứng không tiết lộ thông tin. Mã không tiết lộ thông tin dự kiến sẽ nhận thông tin đó trong server/noir/Prover.toml (được ghi lại tại đây (opens in a new tab)).

Để xem nó hoạt động:

  1. Đảm bảo rằng bạn đã cài đặt Node (opens in a new tab)Noir (opens in a new tab). Tốt nhất, hãy cài đặt chúng trên một hệ thống UNIX như macOS, Linux hoặc WSL (opens in a new tab).

  2. Tải xuống mã giai đoạn 1 và khởi động máy chủ web để phục vụ mã ứng dụng.

    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

    Lý do bạn cần một máy chủ web ở đây là để ngăn chặn một số loại gian lận, nhiều ví (chẳng hạn như MetaMask) không chấp nhận các tệp được phục vụ trực tiếp từ đĩa

  3. Mở một trình duyệt có ví.

  4. Trong ví, nhập một cụm mật khẩu mới. Lưu ý rằng điều này sẽ xóa cụm mật khẩu hiện tại của bạn, vì vậy hãy đảm bảo bạn đã sao lưu.

    Cụm mật khẩu là test test test test test test test test test test test junk, cụm mật khẩu thử nghiệm mặc định cho anvil.

  5. Duyệt đến mã phía máy khách (opens in a new tab).

  6. Kết nối với ví và chọn tài khoản đích và số tiền của bạn.

  7. Nhấp vào và ký giao dịch.

  8. Dưới tiêu đề Prover.toml, bạn sẽ tìm thấy văn bản. Thay thế server/noir/Prover.toml bằng văn bản đó.

  9. Thực hiện bằng chứng không tiết lộ thông tin.

    1cd ../server/noir
    2nargo execute

    Đầu ra sẽ tương tự như

    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. So sánh hai giá trị cuối cùng với giá trị băm bạn thấy trên trình duyệt web để xem liệu thông điệp có được băm chính xác hay không.

server/noir/Prover.toml

Tệp này (opens in a new tab) hiển thị định dạng thông tin mà Noir mong đợi.

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

Thông điệp ở định dạng văn bản, giúp người dùng dễ hiểu (điều cần thiết khi ký) và giúp mã Noir phân tích cú pháp. Số tiền được trích dẫn bằng finney để một mặt cho phép chuyển khoản phân số và mặt khác có thể dễ dàng đọc được. Số cuối cùng là nonce (opens in a new tab).

Chuỗi dài 100 ký tự. Bằng chứng không tiết lộ thông tin không xử lý tốt dữ liệu có kích thước thay đổi, vì vậy thường cần phải đệm dữ liệu.

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

Ba tham số này là các mảng byte có kích thước cố định.

1[[accounts]]
2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
3balance=100_000
4nonce=0
5
6[[accounts]]
7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
8balance=100_000
9nonce=0
Hiện tất cả

Đây là cách để chỉ định một mảng các cấu trúc. Đối với mỗi mục nhập, chúng tôi chỉ định địa chỉ, số dư (tính bằng milliETH hay còn gọi là). finney (opens in a new tab)), và giá trị nonce tiếp theo.

client/src/Transfer.tsx

Tệp này (opens in a new tab) triển khai xử lý phía máy khách và tạo tệp server/noir/Prover.toml (tệp bao gồm các tham số không tiết lộ thông tin).

Dưới đây là giải thích về những phần thú vị hơn.

1export default attrs => {

Hàm này tạo thành phần React Transfer, mà các tệp khác có thể nhập.

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

Đây là các địa chỉ tài khoản, các địa chỉ được tạo bởi cụm mật khẩu test ... test junk` passphrase. Nếu bạn muốn sử dụng địa chỉ của riêng mình, chỉ cần sửa đổi định nghĩa này.

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

Hook Wagmi (opens in a new tab) này cho phép chúng ta truy cập thư viện viem (opens in a new tab) và ví.

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

Đây là thông điệp, được đệm bằng các khoảng trắng. Mỗi khi một trong các biến useState (opens in a new tab) thay đổi, thành phần sẽ được vẽ lại và message được cập nhật.

1 const sign = async () => {

Hàm này được gọi khi người dùng nhấp vào nút . Thông điệp được cập nhật tự động, nhưng chữ ký yêu cầu sự chấp thuận của người dùng trong ví, và chúng tôi không muốn yêu cầu nó trừ khi cần thiết.

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

Yêu cầu ví ký thông điệp (opens in a new tab).

1 const hash = hashMessage(message)

Lấy giá trị băm của thông điệp. Việc cung cấp nó cho người dùng để gỡ lỗi (mã Noir) là rất hữu ích.

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

Lấy khóa công khai (opens in a new tab). Điều này là bắt buộc đối với hàm ecrecover của Noir (opens in a new tab).

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

Đặt các biến trạng thái. Làm điều này sẽ vẽ lại thành phần (sau khi hàm sign thoát) và hiển thị cho người dùng các giá trị đã cập nhật.

1 let proverToml = `

Văn bản cho Prover.toml.

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

Viem cung cấp cho chúng tôi khóa công khai dưới dạng một chuỗi thập lục phân 65 byte. Byte đầu tiên là 0x04, một dấu hiệu phiên bản. Tiếp theo là 32 byte cho x của khóa công khai và sau đó là 32 byte cho y của khóa công khai.

Tuy nhiên, Noir dự kiến sẽ nhận thông tin này dưới dạng hai mảng byte, một cho x và một cho y. Việc phân tích cú pháp ở đây trên ứng dụng sẽ dễ dàng hơn là một phần của bằng chứng không tiết lộ thông tin.

Lưu ý rằng đây là một thực hành tốt trong không tiết lộ thông tin nói chung. Mã bên trong một bằng chứng không tiết lộ thông tin rất tốn kém, vì vậy bất kỳ xử lý nào có thể được thực hiện bên ngoài bằng chứng không tiết lộ thông tin nên được thực hiện bên ngoài bằng chứng không tiết lộ thông tin.

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

Chữ ký cũng được cung cấp dưới dạng một chuỗi thập lục phân 65 byte. Tuy nhiên, byte cuối cùng chỉ cần thiết để khôi phục khóa công khai. Vì khóa công khai sẽ được cung cấp cho mã Noir, chúng ta không cần nó để xác minh chữ ký, và mã Noir không yêu cầu nó.

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

Cung cấp các tài khoản.

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

Đây là định dạng HTML (chính xác hơn là JSX (opens in a new tab)) của thành phần.

server/noir/src/main.nr

Tệp này (opens in a new tab) là mã không tiết lộ thông tin thực tế.

1use std::hash::pedersen_hash;

Hàm băm Pedersen (opens in a new tab) được cung cấp cùng với thư viện chuẩn Noir (opens in a new tab). Bằng chứng không tiết lộ thông tin thường sử dụng hàm băm này. Nó dễ tính toán hơn nhiều bên trong các mạch số học (opens in a new tab) so với các hàm băm tiêu chuẩn.

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

Hai hàm này là các thư viện bên ngoài, được định nghĩa trong Nargo.toml (opens in a new tab). Chúng chính xác như tên gọi của chúng, một hàm tính toán hàm băm keccak256 (opens in a new tab) và một hàm xác minh chữ ký Ethereum và khôi phục địa chỉ Ethereum của người ký.

1global ACCOUNT_NUMBER : u32 = 5;

Noir được lấy cảm hứng từ Rust (opens in a new tab). Các biến, theo mặc định, là các hằng số. Đây là cách chúng ta định nghĩa các hằng số cấu hình toàn cục. Cụ thể, ACCOUNT_NUMBER là số lượng tài khoản chúng ta lưu trữ.

Các kiểu dữ liệu có tên u<number> là số bit đó, không dấu. Các loại được hỗ trợ duy nhất là u8, u16, u32, u64u128.

1global FLAT_ACCOUNT_FIELDS : u32 = 2;

Biến này được sử dụng cho hàm băm Pedersen của các tài khoản, như được giải thích bên dưới.

1global MESSAGE_LENGTH : u32 = 100;

Như đã giải thích ở trên, độ dài thông điệp là cố định. Nó được chỉ định ở đây.

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

Chữ ký EIP-191 (opens in a new tab) yêu cầu một bộ đệm với tiền tố 26 byte, theo sau là độ dài thông điệp bằng ASCII, và cuối cùng là chính thông điệp.

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

Thông tin chúng tôi lưu trữ về một tài khoản. Field (opens in a new tab) là một số, thường lên đến 253 bit, có thể được sử dụng trực tiếp trong mạch số học (opens in a new tab) triển khai bằng chứng không tiết lộ thông tin. Ở đây chúng ta sử dụng Field để lưu trữ một địa chỉ Ethereum 160-bit.

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

Thông tin chúng tôi lưu trữ cho một giao dịch chuyển khoản.

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

Một định nghĩa hàm. Tham số là thông tin Account. Kết quả là một mảng các biến Field, có độ dài là FLAT_ACCOUNT_FIELDS

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

Giá trị đầu tiên trong mảng là địa chỉ tài khoản. Giá trị thứ hai bao gồm cả số dư và nonce. Các lệnh gọi .into() thay đổi một số thành kiểu dữ liệu mà nó cần. account.nonce là một giá trị u32, nhưng để cộng nó vào account.balance « 32, một giá trị u128, nó cần phải là một u128. Đó là .into() đầu tiên. Cái thứ hai chuyển đổi kết quả u128 thành một Field để nó vừa với mảng.

1 flat
2}

Trong Noir, các hàm chỉ có thể trả về một giá trị ở cuối (không có trả về sớm). Để chỉ định giá trị trả về, bạn đánh giá nó ngay trước dấu ngoặc đóng của hàm.

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

Hàm này biến mảng tài khoản thành một mảng Field, có thể được sử dụng làm đầu vào cho Hàm băm Petersen.

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

Đây là cách bạn chỉ định một biến có thể thay đổi, tức là không phải là hằng số. Các biến trong Noir phải luôn có một giá trị, vì vậy chúng ta khởi tạo biến này thành toàn số không.

1 for i in 0..ACCOUNT_NUMBER {

Đây là một vòng lặp for. Lưu ý rằng các ranh giới là hằng số. Các vòng lặp Noir phải có các ranh giới của chúng được biết tại thời điểm biên dịch. Lý do là các mạch số học không hỗ trợ điều khiển luồng. Khi xử lý một vòng lặp for, trình biên dịch chỉ đơn giản là đặt mã bên trong nó nhiều lần, một lần cho mỗi lần lặp.

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}
Hiện tất cả

Cuối cùng, chúng ta đã đến hàm băm mảng tài khoản.

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 }

Hàm này tìm tài khoản có một địa chỉ cụ thể. Hàm này sẽ cực kỳ kém hiệu quả trong mã tiêu chuẩn vì nó lặp qua tất cả các tài khoản, ngay cả sau khi đã tìm thấy địa chỉ.

Tuy nhiên, trong bằng chứng không tiết lộ thông tin, không có điều khiển luồng. Nếu chúng ta cần kiểm tra một điều kiện, chúng ta phải kiểm tra nó mỗi lần.

Một điều tương tự xảy ra với các câu lệnh if. Câu lệnh if trong vòng lặp trên được dịch thành các câu lệnh toán học này.

kết quảđiều kiện = tài khoản[i].địa chỉ == địa chỉ // một nếu chúng bằng nhau, không nếu ngược lại

tài khoảnmới = kết quảđiều kiện*i + (1-kết quảđiều kiện)*tài khoản

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

Hàm assert (opens in a new tab) làm cho bằng chứng không tiết lộ thông tin bị sập nếu khẳng định là sai. Trong trường hợp này, nếu chúng ta không thể tìm thấy một tài khoản với địa chỉ liên quan. Để báo cáo địa chỉ, chúng tôi sử dụng một chuỗi định dạng (opens in a new tab).

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

Hàm này áp dụng một giao dịch chuyển khoản và trả về mảng tài khoản mới.

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);

Chúng tôi không thể truy cập các phần tử cấu trúc bên trong một chuỗi định dạng trong Noir, vì vậy chúng tôi tạo một bản sao có thể sử dụng được.

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}");

Đây là hai điều kiện có thể làm cho một giao dịch không hợp lệ.

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}

Tạo mảng tài khoản mới và sau đó trả về nó.

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

Hàm này đọc địa chỉ từ thông điệp.

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

Địa chỉ luôn dài 20 byte (hay còn gọi là 40 chữ số thập lục phân), và bắt đầu ở ký tự #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)
Hiện tất cả

Đọc số tiền và nonce từ thông điệp.

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;

Trong thông điệp, số đầu tiên sau địa chỉ là số lượng finney (hay còn gọi là một phần nghìn của một ETH) để chuyển. Số thứ hai là nonce. Bất kỳ văn bản nào giữa chúng đều bị bỏ qua.

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 { // We just found it
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}
Hiện tất cả

Trả về một bộ (opens in a new tab) là cách của Noir để trả về nhiều giá trị từ một hàm.

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}
Hiện tất cả

Hàm này chuyển đổi thông điệp thành các byte, sau đó chuyển đổi số tiền thành một TransferTxn.

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

Chúng tôi có thể sử dụng Hàm băm Pedersen cho các tài khoản vì chúng chỉ được băm bên trong bằng chứng không tiết lộ thông tin. Tuy nhiên, trong mã này, chúng tôi cần kiểm tra chữ ký của thông điệp, được tạo bởi trình duyệt. Để làm được điều đó, chúng ta cần tuân theo định dạng ký của Ethereum trong EIP 191 (opens in a new tab). Điều này có nghĩa là chúng ta cần tạo một bộ đệm kết hợp với một tiền tố tiêu chuẩn, độ dài thông điệp bằng ASCII, và chính thông điệp, và sử dụng hàm keccak256 tiêu chuẩn của Ethereum để băm nó.

1 // ASCII prefix
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 ];
Hiện tất cả

Để tránh các trường hợp một ứng dụng yêu cầu người dùng ký một thông điệp có thể được sử dụng như một giao dịch hoặc cho một mục đích khác, EIP 191 chỉ định rằng tất cả các thông điệp đã ký bắt đầu bằng ký tự 0x19 (không phải là ký tự ASCII hợp lệ) theo sau là Ethereum Signed Message: và một dòng mới.

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");
Hiện tất cả

Xử lý độ dài thông điệp lên đến 999 và thất bại nếu lớn hơn. Tôi đã thêm mã này, mặc dù độ dài thông điệp là một hằng số, bởi vì nó giúp việc thay đổi nó dễ dàng hơn. Trên một hệ thống sản xuất, bạn có thể chỉ cần giả định MESSAGE_LENGTH không thay đổi để có hiệu suất tốt hơn.

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

Sử dụng hàm keccak256 tiêu chuẩn của Ethereum.

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

Hàm này xác minh chữ ký, yêu cầu giá trị băm của thông điệp. Sau đó, nó cung cấp cho chúng tôi địa chỉ đã ký nó và giá trị băm của thông điệp. Giá trị băm của thông điệp được cung cấp trong hai giá trị Field vì chúng dễ sử dụng hơn trong phần còn lại của chương trình so với một mảng byte.

Chúng ta cần sử dụng hai giá trị Field vì các phép tính trường được thực hiện theo modulo (opens in a new tab) một số lớn, nhưng số đó thường nhỏ hơn 256 bit (nếu không sẽ khó thực hiện các phép tính đó trong 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 }

Chỉ định hash1hash2 là các biến có thể thay đổi, và ghi giá trị băm vào chúng từng byte một.

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

Điều này tương tự như ecrecover của Solidity (opens in a new tab), với hai điểm khác biệt quan trọng:

  • Nếu chữ ký không hợp lệ, lệnh gọi sẽ thất bại ở assert và chương trình sẽ bị hủy bỏ.
  • Mặc dù khóa công khai có thể được khôi phục từ chữ ký và giá trị băm, đây là quá trình xử lý có thể được thực hiện bên ngoài và do đó, không đáng để thực hiện bên trong bằng chứng không tiết lộ thông tin. Nếu ai đó cố gắng lừa chúng ta ở đây, việc xác minh chữ ký sẽ thất bại.
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, // Hash of old accounts array
14 Field, // Hash of new accounts array
15 Field, // First 16 bytes of message hash
16 Field, // Last 16 bytes of message hash
17 )
Hiện tất cả

Cuối cùng, chúng ta đến hàm main. Chúng ta cần chứng minh rằng chúng ta có một giao dịch thay đổi hợp lệ giá trị băm của tài khoản từ giá trị cũ sang giá trị mới. Chúng tôi cũng cần chứng minh rằng nó có giá trị băm giao dịch cụ thể này để người gửi nó biết giao dịch của họ đã được xử lý.

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

Chúng tôi cần txn có thể thay đổi vì chúng tôi không đọc địa chỉ từ thông điệp, chúng tôi đọc nó từ chữ ký.

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}
Hiện tất cả

Giai đoạn 2 - Thêm một máy chủ

Trong giai đoạn thứ hai, chúng tôi thêm một máy chủ nhận và thực hiện các giao dịch chuyển khoản từ trình duyệt.

Để xem nó hoạt động:

  1. Dừng Vite nếu nó đang chạy.

  2. Tải xuống nhánh bao gồm máy chủ và đảm bảo bạn có tất cả các mô-đun cần thiết.

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

    Không cần biên dịch mã Noir, nó giống như mã bạn đã sử dụng cho giai đoạn 1.

  3. Khởi động máy chủ.

    1npm run start
  4. Trong một cửa sổ dòng lệnh riêng, chạy Vite để phục vụ mã trình duyệt.

    1cd client
    2npm run dev
  5. Duyệt đến mã ứng dụng tại http://localhost:5173 (opens in a new tab)

  6. Trước khi bạn có thể phát hành một giao dịch, bạn cần biết nonce, cũng như số tiền bạn có thể gửi. Để có được thông tin này, hãy nhấp vào Cập nhật dữ liệu tài khoản và ký thông điệp.

    Chúng ta có một tình thế tiến thoái lưỡng nan ở đây. Một mặt, chúng tôi không muốn ký một thông điệp có thể được sử dụng lại (một cuộc tấn công phát lại (opens in a new tab)), đó là lý do tại sao chúng tôi muốn có một nonce ngay từ đầu. Tuy nhiên, chúng ta chưa có nonce. Giải pháp là chọn một nonce chỉ có thể được sử dụng một lần và chúng ta đã có ở cả hai phía, chẳng hạn như thời gian hiện tại.

    Vấn đề với giải pháp này là thời gian có thể không được đồng bộ hóa hoàn hảo. Vì vậy, thay vào đó, chúng tôi ký một giá trị thay đổi mỗi phút. Điều này có nghĩa là cửa sổ dễ bị tấn công phát lại của chúng ta chỉ kéo dài tối đa một phút. Xét rằng trong sản xuất, yêu cầu đã ký sẽ được bảo vệ bởi TLS, và phía bên kia của đường hầm---máy chủ---đã có thể tiết lộ số dư và nonce (nó phải biết chúng để hoạt động), đây là một rủi ro chấp nhận được.

  7. Sau khi trình duyệt nhận lại số dư và nonce, nó sẽ hiển thị biểu mẫu chuyển khoản. Chọn địa chỉ đích và số tiền rồi nhấp vào Chuyển khoản. Ký yêu cầu này.

  8. Để xem giao dịch chuyển khoản, hãy Cập nhật dữ liệu tài khoản hoặc xem trong cửa sổ nơi bạn chạy máy chủ. Máy chủ ghi lại trạng thái mỗi khi nó thay đổi.

    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)
    Hiện tất cả

server/index.mjs

Tệp này (opens in a new tab) chứa quy trình máy chủ và tương tác với mã Noir tại main.nr (opens in a new tab). Đây là lời giải thích về những phần thú vị.

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

Thư viện noir.js (opens in a new tab) giao tiếp giữa mã JavaScript và mã Noir.

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

Tải mạch số học---chương trình Noir đã biên dịch mà chúng ta đã tạo ở giai đoạn trước---và chuẩn bị để thực thi nó.

1// We only provide account information in return to a signed request
2const accountInformation = async signature => {
3 const fromAddress = await recoverAddress({
4 hash: hashMessage("Lấy dữ liệu tài khoản " + Math.floor((new Date().getTime())/60000)),
5 signature
6 })

Để cung cấp thông tin tài khoản, chúng tôi chỉ cần chữ ký. Lý do là chúng tôi đã biết thông điệp sẽ là gì, và do đó là giá trị băm của thông điệp.

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

Xử lý một thông điệp và thực hiện giao dịch mà nó mã hóa.

1 // Get the public key
2 const pubKey = await recoverPublicKey({
3 hash,
4 signature
5 })

Bây giờ chúng ta chạy JavaScript trên máy chủ, chúng ta có thể truy xuất khóa công khai ở đó thay vì trên ứng dụng.

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 })
Hiện tất cả

noir.execute chạy chương trình Noir. Các tham số tương đương với các tham số được cung cấp trong Prover.toml (opens in a new tab). Lưu ý rằng các giá trị dài được cung cấp dưới dạng một mảng các chuỗi thập lục phân (["0x60", "0xA7"]), không phải là một giá trị thập lục phân duy nhất (0x60A7), theo cách Viem thực hiện.

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

Nếu có lỗi, hãy bắt nó và sau đó chuyển tiếp một phiên bản đơn giản hóa cho ứng dụng.

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

Áp dụng giao dịch. Chúng tôi đã làm điều đó trong mã Noir, nhưng việc làm lại ở đây dễ hơn là trích xuất kết quả từ đó.

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

Cấu trúc Tài khoản ban đầu.

Giai đoạn 3 - Hợp đồng thông minh Ethereum

  1. Dừng các quy trình máy chủ và ứng dụng.

  2. Tải xuống nhánh có hợp đồng thông minh và đảm bảo bạn có tất cả các mô-đun cần thiết.

    1git checkout 03-smart-contracts
    2cd client
    3npm install
    4cd ../server
    5npm install
  3. Chạy anvil trong một cửa sổ dòng lệnh riêng.

  4. Tạo khóa xác minh và trình xác minh solidity, sau đó sao chép mã trình xác minh vào dự án 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. Đi đến các hợp đồng thông minh và đặt các biến môi trường để sử dụng chuỗi khối anvil.

    1cd ../../smart-contracts
    2export ETH_RPC_URL=http://localhost:8545
    3ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
  6. Triển khai Verifier.sol và lưu trữ địa chỉ trong một biến môi trường.

    1VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`
    2echo $VERIFIER_ADDRESS
  7. Triển khai hợp đồng 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

    Giá trị 0x199..67b là hàm băm Pederson của trạng thái ban đầu của Tài khoản. Nếu bạn sửa đổi trạng thái ban đầu này trong server/index.mjs, bạn có thể chạy một giao dịch để xem giá trị băm ban đầu được báo cáo bởi bằng chứng không tiết lộ thông tin.

  8. Chạy máy chủ.

    1cd ../server
    2npm run start
  9. Chạy ứng dụng trong một cửa sổ dòng lệnh khác.

    1cd client
    2npm run dev
  10. Chạy một số giao dịch.

  11. Để xác minh rằng trạng thái đã thay đổi trên chuỗi, hãy khởi động lại quy trình máy chủ. Xem rằng ZkBank không còn chấp nhận giao dịch, vì giá trị băm ban đầu trong các giao dịch khác với giá trị băm được lưu trữ trên chuỗi.

    Đây là loại lỗi dự kiến.

    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
    7Lỗi xác minh: ContractFunctionExecutionError: Hàm hợp đồng "processTransaction" đã bị đảo ngược với lý do sau:
    8Giá trị băm trạng thái cũ sai
    9
    10Contract Call:
    11 address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
    12 function: processTransaction(bytes _proof, bytes32[] _publicInputs)
    13 args: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000
    Hiện tất cả

server/index.mjs

Những thay đổi trong tệp này chủ yếu liên quan đến việc tạo bằng chứng thực tế và gửi nó lên chuỗi.

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

Chúng tôi cần sử dụng gói Barretenberg (opens in a new tab) để tạo bằng chứng thực tế để gửi lên chuỗi. Chúng ta có thể sử dụng gói này bằng cách chạy giao diện dòng lệnh (bb) hoặc bằng cách sử dụng thư viện JavaScript, bb.js (opens in a new tab). Thư viện JavaScript chậm hơn nhiều so với việc chạy mã gốc, vì vậy chúng tôi sử dụng exec (opens in a new tab) ở đây để sử dụng dòng lệnh.

Lưu ý rằng nếu bạn quyết định sử dụng bb.js, bạn cần sử dụng phiên bản tương thích với phiên bản Noir mà bạn đang sử dụng. Tại thời điểm viết bài, phiên bản Noir hiện tại (1.0.0-beta.11) sử dụng bb.js phiên bản 0.87.

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

Địa chỉ ở đây là địa chỉ bạn nhận được khi bắt đầu với một anvil sạch và làm theo các hướng dẫn ở trên.

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

Khóa riêng tư này là một trong những tài khoản được cấp tiền trước mặc định trong anvil.

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

Tạo một bằng chứng bằng cách sử dụng tệp thực thi bb.

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

Ghi nhân chứng vào một tệp.

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

Thực sự tạo ra bằng chứng. Bước này cũng tạo ra một tệp với các biến công khai, nhưng chúng tôi không cần đến nó. Chúng tôi đã có những biến đó từ noir.execute.

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

Bằng chứng là một mảng JSON các giá trị Field, mỗi giá trị được biểu diễn dưới dạng một giá trị thập lục phân. Tuy nhiên, chúng ta cần gửi nó trong giao dịch dưới dạng một giá trị bytes duy nhất, mà Viem biểu diễn bằng một chuỗi thập lục phân lớn. Ở đây chúng ta thay đổi định dạng bằng cách nối tất cả các giá trị, loại bỏ tất cả các 0x, và sau đó thêm một cái ở cuối.

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

Dọn dẹp và trả về bằng chứng.

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

Các trường công khai cần phải là một mảng các giá trị 32 byte. Tuy nhiên, vì chúng tôi cần phải chia giá trị băm giao dịch giữa hai giá trị Field, nó xuất hiện dưới dạng một giá trị 16 byte. Ở đây chúng tôi thêm các số không để Viem hiểu rằng nó thực sự là 32 byte.

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

Mỗi địa chỉ chỉ sử dụng mỗi nonce một lần để chúng ta có thể sử dụng kết hợp fromAddressnonce làm định danh duy nhất cho tệp nhân chứng và thư mục đầu ra.

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}
Hiện tất cả

Gửi giao dịch lên chuỗi.

smart-contracts/src/ZkBank.sol

Đây là mã trên chuỗi nhận giao dịch.

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 }
Hiện tất cả

Mã trên chuỗi cần theo dõi hai biến: trình xác minh (một hợp đồng riêng biệt được tạo bởi nargo) và giá trị băm trạng thái hiện tại.

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

Mỗi khi trạng thái thay đổi, chúng tôi phát ra một sự kiện TransactionProcessed.

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

Hàm này xử lý các giao dịch. Nó nhận bằng chứng (dưới dạng bytes) và các đầu vào công khai (dưới dạng một mảng bytes32), theo định dạng mà trình xác minh yêu cầu (để giảm thiểu xử lý trên chuỗi và do đó là chi phí gas).

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

Bằng chứng không tiết lộ thông tin cần phải là giao dịch thay đổi từ giá trị băm hiện tại của chúng ta thành một giá trị mới.

1 myVerifier.verify(_proof, _publicFields);

Gọi hợp đồng xác minh để xác minh bằng chứng không tiết lộ thông tin. Bước này sẽ đảo ngược giao dịch nếu bằng chứng không tiết lộ thông tin sai.

1 currentStateHash = _publicFields[1];
2
3 emit TransactionProcessed(
4 _publicFields[2]<<128 | _publicFields[3],
5 _publicFields[0],
6 _publicFields[1]
7 );
8 }
9}
Hiện tất cả

Nếu mọi thứ đều ổn, hãy cập nhật giá trị băm trạng thái thành giá trị mới và phát ra một sự kiện TransactionProcessed.

Lạm dụng bởi thành phần tập trung

Bảo mật thông tin bao gồm ba thuộc tính:

  • Tính bảo mật, người dùng không thể đọc thông tin mà họ không được ủy quyền để đọc.
  • Tính toàn vẹn, thông tin không thể bị thay đổi ngoại trừ bởi người dùng được ủy quyền theo cách được ủy quyền.
  • Tính khả dụng, người dùng được ủy quyền có thể sử dụng hệ thống.

Trên hệ thống này, tính toàn vẹn được cung cấp thông qua bằng chứng không tiết lộ thông tin. Tính khả dụng khó đảm bảo hơn nhiều, và tính bảo mật là không thể, vì ngân hàng phải biết số dư của mỗi tài khoản và tất cả các giao dịch. Không có cách nào để ngăn chặn một thực thể có thông tin chia sẻ thông tin đó.

Có thể tạo ra một ngân hàng thực sự bí mật bằng cách sử dụng địa chỉ ẩn (opens in a new tab), nhưng điều đó nằm ngoài phạm vi của bài viết này.

Thông tin sai lệch

Một cách mà máy chủ có thể vi phạm tính toàn vẹn là cung cấp thông tin sai khi dữ liệu được yêu cầu (opens in a new tab).

Để giải quyết vấn đề này, chúng ta có thể viết một chương trình Noir thứ hai nhận các tài khoản làm đầu vào riêng tư và địa chỉ mà thông tin được yêu cầu làm đầu vào công khai. Đầu ra là số dư và nonce của địa chỉ đó, và giá trị băm của các tài khoản.

Tất nhiên, bằng chứng này không thể được xác minh trên chuỗi, vì chúng tôi không muốn đăng nonce và số dư trên chuỗi. Tuy nhiên, nó có thể được xác minh bởi mã ứng dụng chạy trong trình duyệt.

Giao dịch bắt buộc

Cơ chế thông thường để đảm bảo tính khả dụng và ngăn chặn kiểm duyệt trên L2 là giao dịch bắt buộc (opens in a new tab). Nhưng các giao dịch bắt buộc không kết hợp với các bằng chứng không tiết lộ thông tin. Máy chủ là thực thể duy nhất có thể xác minh các giao dịch.

Chúng tôi có thể sửa đổi smart-contracts/src/ZkBank.sol để chấp nhận các giao dịch bắt buộc và ngăn máy chủ thay đổi trạng thái cho đến khi chúng được xử lý. Tuy nhiên, điều này mở ra cho chúng ta một cuộc tấn công từ chối dịch vụ đơn giản. Điều gì sẽ xảy ra nếu một giao dịch bắt buộc không hợp lệ và do đó không thể xử lý được?

Giải pháp là có một bằng chứng không tiết lộ thông tin rằng một giao dịch bắt buộc là không hợp lệ. Điều này cho máy chủ ba lựa chọn:

  • Xử lý giao dịch bắt buộc, cung cấp bằng chứng không tiết lộ thông tin rằng nó đã được xử lý và giá trị băm trạng thái mới.
  • Từ chối giao dịch bắt buộc, và cung cấp một bằng chứng không tiết lộ thông tin cho hợp đồng rằng giao dịch không hợp lệ (địa chỉ không xác định, nonce xấu, hoặc số dư không đủ).
  • Bỏ qua giao dịch bắt buộc. Không có cách nào để buộc máy chủ thực sự xử lý giao dịch, nhưng điều đó có nghĩa là toàn bộ hệ thống không khả dụng.

Trái phiếu khả dụng

Trong một triển khai thực tế, có thể sẽ có một số loại động cơ lợi nhuận để giữ cho máy chủ hoạt động. Chúng tôi có thể củng cố động cơ này bằng cách yêu cầu máy chủ đăng một trái phiếu khả dụng mà bất kỳ ai cũng có thể đốt nếu một giao dịch bắt buộc không được xử lý trong một khoảng thời gian nhất định.

Mã Noir xấu

Thông thường, để mọi người tin tưởng vào một hợp đồng thông minh, chúng tôi tải mã nguồn lên một trình duyệt khối (opens in a new tab). Tuy nhiên, trong trường hợp bằng chứng không tiết lộ thông tin, điều đó là không đủ.

Verifier.sol chứa khóa xác minh, là một hàm của chương trình Noir. Tuy nhiên, khóa đó không cho chúng tôi biết chương trình Noir là gì. Để thực sự có một giải pháp đáng tin cậy, bạn cần tải lên chương trình Noir (và phiên bản đã tạo ra nó). Nếu không, các bằng chứng không tiết lộ thông tin có thể phản ánh một chương trình khác, một chương trình có cửa hậu.

Cho đến khi các trình duyệt khối bắt đầu cho phép chúng tôi tải lên và xác minh các chương trình Noir, bạn nên tự làm điều đó (tốt nhất là với IPFS). Sau đó, những người dùng tinh vi sẽ có thể tải xuống mã nguồn, tự biên dịch nó, tạo Verifier.sol, và xác minh rằng nó giống hệt với mã trên chuỗi.

Kết luận

Các ứng dụng loại Plasma yêu cầu một thành phần tập trung làm nơi lưu trữ thông tin. Điều này mở ra các lỗ hổng tiềm ẩn nhưng, đổi lại, cho phép chúng ta bảo vệ quyền riêng tư theo những cách không có sẵn trên chính chuỗi khối. Với bằng chứng không tiết lộ thông tin, chúng ta có thể đảm bảo tính toàn vẹn và có thể làm cho việc duy trì tính khả dụng trở nên có lợi về mặt kinh tế cho bất kỳ ai đang chạy thành phần tập trung.

Xem thêm công việc của tôi tại đây (opens in a new tab).

Ghi nhận

  • Josh Crites đã đọc một bản nháp của bài viết này và giúp tôi giải quyết một vấn đề gai góc về Noir.

Bất kỳ lỗi còn lại nào đều là trách nhiệm của tôi.

Lần cập nhật trang lần cuối: 28 tháng 10, 2025

Hướng dẫn này có hữu ích không?