Chuyển đến nội dung chính

Viết một Plasma dành riêng cho ứng dụng giúp bảo vệ quyền riêng tư

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

Giới thiệu

Trái ngược với các bản cuộn (rollup), Plasma sử dụng Mạng chính Ethereum cho tính toàn vẹn, nhưng không phải cho tính khả dụng. Trong bài viết này, chúng ta 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 có tính khả dụng (một thành phần tập trung có thể gặp sự cố và vô hiệu hóa toàn bộ hệ thống).

Ứng dụng mà chúng ta 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 mã băm của trạng thái (các tài khoản và số dư của họ) và các giao dịch, nhưng giữ số dư thực tế ngoài chuỗi nơi chúng có thể giữ được quyền 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 kiến thức. Đố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 mã băm trạng thái và cung cấp các bằng chứng Merkle cho các số dư cần thiết.

  • Lưu trữ 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 tồn chúng trong trường hợp khởi động lại. Ở đây, việc thông tin bị mất đi cũng không sao.

  • Chỉ chuyển tiề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 được giới hạn trong việc chuyển tiền.

Các bằng chứng không kiến thức

Ở cấp độ cơ bản, một bằng chứng không kiến thức cho thấy trình chứng minh biết một số dữ liệu, Dataprivate sao cho có một mối quan hệ Relationship giữa một số dữ liệu công khai, Datapublic, và Dataprivate. Trình xác minh biết Relationship và Datapublic.

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

Trong hầu hết các trường hợp, Dataprivate là đầu vào cho chương trình bằng chứng không kiến thức, và Datapublic là đầu ra.

Các trường này trong Dataprivate:

  • Staten, trạng thái cũ
  • Staten+1, trạng thái mới
  • Transaction, 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:
    • Destination address (Địa chỉ đích) nhận chuyển khoản
    • Amount (Số lượng) đượ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ý.
  • Signature, 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 một giao dịch là địa chỉ nguồn. Bởi vì hệ thống không tri thức của chúng ta hoạt động theo cách như vậy, 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 Datapublic:

  • Hash(Staten) mã băm của trạng thái cũ
  • Hash(Staten+1) mã băm của trạng thái mới
  • Hash(Transaction) mã băm của giao dịch thay đổi trạng thái từ Staten sang Staten+1.

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

  • Các mã băm công khai thực sự là các mã 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.

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

Cấu trúc dữ liệu

Cấu trúc dữ liệu chính là trạng thái được lưu trữ bởi máy chủ. Đố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 (mở trong tab mới), được sử dụng để ngăn chặn các cuộc tấn công phát lại (replay attack) (mở trong tab mới).

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 các giao dịch, xử lý chúng, và đăng các mã băm lên chuỗi cùng với các bằng chứng không kiến thức.
  • Một hợp đồng thông minh lưu trữ các mã băm và xác minh các bằng chứng không kiến thức để đảm bảo các chuyển đổi trạng thái là hợp lệ.

Luồng dữ liệu và kiểm soát

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

  1. 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ý sang một tài khoản khác.

  2. Máy chủ xác minh rằng giao dịch 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 khỏi số dư của người ký và thêm nó 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 kiến thức rằng sự thay đổi trạng thái là hợp lệ.

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

    • Mã băm trạng thái mới
    • Mã 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 kiến thức chứng minh rằng việc chuyển 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 kiến thức.

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

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

Các công cụ

Đối với mã phía máy khách, chúng ta sẽ sử dụng Vite (mở trong tab mới), React (mở trong tab mới), Viem (mở trong tab mới) và Wagmi (mở trong tab mới). Đây là những công cụ tiêu chuẩn trong 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 (mở trong tab mới). Phần không tri thức được viết bằng Noir (mở trong tab mới). 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 (mở trong tab mới), hãy chạy:

noirup -v 1.0.0-beta.10

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

Triển khai

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

Giai đoạn 1 - Không tri thức thủ công

Trong giai đoạn đầu, chúng ta sẽ ký một giao dịch trên trình duyệt và sau đó cung cấp thủ công thông tin cho bằng chứng không kiến thức. Mã không tri thức kỳ vọng sẽ nhận được thông tin đó trong server/noir/Prover.toml (được tài liệu hóa tại đây (mở trong tab mới)).

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

  1. Đảm bảo bạn đã cài đặt Node (mở trong tab mới) và Noir (mở trong tab mới). Tốt nhất, hãy cài đặt chúng trên hệ thống UNIX như macOS, Linux hoặc WSL (mở trong tab mới).

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

    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
    

    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ó cài ví.

  4. Trong ví, nhập cụm mật khẩu (passphrase) mới. Lưu ý rằng thao tác 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 (mở trong tab mới).

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

  7. Nhấp vào Sign 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 thi bằng chứng không kiến thức.

    cd ../server/noir
    nargo execute
    

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

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

server/noir/Prover.toml

Tệp này (mở trong tab mới) hiển thị định dạng thông tin mà Noir kỳ vọng.

message="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 này cần thiết khi ký) và mã Noir dễ dàng phân tích cú pháp. Số lượng được trích dẫn bằng finney để một mặt cho phép chuyển các khoản thập phân và mặt khác dễ đọc. Số cuối cùng là nonce (mở trong tab mới).

Chuỗi dài 100 ký tự. Các bằng chứng không kiến thức 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 chèn thêm (pad) dữ liệu.

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

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

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

client/src/Transfer.tsx

Tệp này (mở trong tab mới) 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 tri thức).

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

export 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 vào.

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

Đâ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. Nếu bạn muốn sử dụng các địa chỉ của riêng mình, chỉ cần sửa đổi định nghĩa này.

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

Các hook Wagmi (mở trong tab mới) này cho phép chúng ta truy cập thư viện Viem (mở trong tab mới) và ví.

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

Đây là thông điệp, được chèn thêm các khoảng trắng. Mỗi khi một trong những biến useState (mở trong tab mới) thay đổi, thành phần sẽ được vẽ lại và message được cập nhật.

  const sign = async () => {

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

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

Yêu cầu ví ký thông điệp (mở trong tab mới).

    const hash = hashMessage(message)

Lấy mã băm thông điệp. Sẽ rất hữu ích khi cung cấp nó cho người dùng để gỡ lỗi (đối với mã Noir).

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

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

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

Thiết lập các biến trạng thái. Việc làm này 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 cập nhật.

    let proverToml = `

Văn bản cho Prover.toml.

message="${message}"

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

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

Tuy nhiên, Noir kỳ vọng nhận được thông tin này dưới dạng hai mảng byte, một cho x và một cho y. Sẽ dễ dàng hơn nếu phân tích cú pháp nó ở đây trên máy khách thay vì coi nó như một phần của bằng chứng không kiến thức.

Lưu ý rằng đây là thực tiễn tốt (good practice) trong không tri thức nói chung. Mã bên trong một bằng chứng không kiến thức rất đắt đỏ, vì vậy bất kỳ hoạt động xử lý nào có thể được thực hiện bên ngoài bằng chứng không kiến thức thì nên được thực hiện bên ngoài bằng chứng không kiến thức.

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

Chữ ký cũng được cung cấp dưới dạng chuỗi thập lục phân dài 65 byte. Tuy nhiên, byte cuối cùng chỉ cần thiết để phục hồi 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 cũng không yêu cầu nó.

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

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

    setProverToml(proverToml)
  }

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

Đây là định dạng HTML (chính xác hơn là JSX (mở trong tab mới)) của thành phần.

server/noir/src/main.nr

Tệp này (mở trong tab mới) là mã không tri thức thực tế.

use std::hash::pedersen_hash;

Mã băm Pedersen (mở trong tab mới) được cung cấp kèm theo thư viện tiêu chuẩn của Noir (mở trong tab mới). Các bằng chứng không kiến thức 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 (arithmetic circuit) (mở trong tab mới) so với các hàm băm tiêu chuẩn.

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

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

global ACCOUNT_NUMBER : u32 = 5;

Noir được truyền cảm hứng bởi Rust (mở trong tab mới). 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 mà chúng ta lưu trữ.

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

global FLAT_ACCOUNT_FIELDS : u32 = 2;

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

global 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.

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

Các chữ ký EIP-191 (mở trong tab mới) yêu cầu một bộ đệm có tiền tố 26 byte, tiếp theo là độ dài thông điệp trong mã ASCII và cuối cùng là chính thông điệp đó.

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

Thông tin mà chúng ta lưu trữ về một tài khoản. Field (mở trong tab mới) 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 (mở trong tab mới) triển khai bằng chứng không kiến thức. Ở đây chúng ta sử dụng Field để lưu trữ địa chỉ Ethereum 160 bit.

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

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

fn 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

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

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ời 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ới account.balance << 32, một giá trị u128, nó cần phải là kiểu u128. Đó là lời gọi .into() đầu tiên. Lời gọi thứ hai chuyển đổi kết quả u128 thành một Field để nó vừa với mảng.

    flat
}

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.

fn 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, mảng này có thể được sử dụng làm đầu vào cho Mã băm Pedersen.

    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 (mutable), nghĩa là không phải một hằng số. Các biến trong Noir phải luôn có giá trị, vì vậy chúng ta khởi tạo biến này thành toàn số không.

    for i in 0..ACCOUNT_NUMBER {

Đây là một vòng lặp for. Lưu ý rằng các giới hạn là hằng số. Các vòng lặp Noir phải có giới hạn đượ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ợ kiểm soát 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 bước lặp.

Cuối cùng, chúng ta đã đến với hàm thực hiện việc băm mảng các tài khoản.

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

Hàm này tìm kiế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 các bằng chứng không kiến thức, không có sự kiểm soát 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.

Đ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 biên dịch thành các mệnh đề toán học này.

conditionresult = accounts[i].address == address // là 1 nếu chúng bằng nhau, ngược lại là 0

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

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

    account
}

Hàm assert (mở trong tab mới) làm cho bằng chứng không kiến thức bị lỗi (crash) 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 tài khoản có địa chỉ liên quan. Để báo cáo địa chỉ, chúng ta sử dụng một chuỗi định dạng (mở trong tab mới).

fn 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 các tài khoản mới.

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

Chúng ta 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 ta tạo một bản sao khả dụng.

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

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

    let mut newAccounts = accounts;

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

    newAccounts
}

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

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

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

{
    let mut result : Field = 0;

    for i in 7..47 {

Địa chỉ luôn dài 20 byte (tức là 40 chữ số thập lục phân) và bắt đầu ở ký tự #7.

Đọc số lượng và nonce từ thông điệp.

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

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

Trả về một bộ dữ liệu (tuple) (mở trong tab mới) là cách của Noir để trả về nhiều giá trị từ một hàm.

Hàm này chuyển đổi thông điệp thành các byte, sau đó chuyển đổi các số lượng thành TransferTxn.

// Tương đương với hashMessage của Viem
// https://viem.sh/docs/utilities/hashMessage#hashmessage
fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {

Chúng ta có thể sử dụng 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 kiến thức. Tuy nhiên, trong đoạn mã này, chúng ta 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 việc ký Ethereum trong EIP-191 (mở trong tab mới). Đ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 tiền tố tiêu chuẩn, độ dài thông điệp theo chuẩn ASCII và chính thông điệp đó, và sử dụng keccak256 tiêu chuẩn của Ethereum để băm nó.

Để 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 số 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.

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

    keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
}

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

fn signatureToAddressAndHash(
        message: str<MESSAGE_LENGTH>, 
        pubKeyX: [u8; 32],
        pubKeyY: [u8; 32],
        signature: [u8; 64]
    ) -> (Field, Field, Field)   // Địa chỉ, 16 byte đầu tiên của mã băm, 16 byte cuối cùng của mã băm        
{

Hàm này xác minh chữ ký, yêu cầu có mã băm thông điệp. Sau đó nó cung cấp cho chúng ta địa chỉ đã ký nó và mã băm thông điệp. Mã băm thông điệp được cung cấp trong hai giá trị Field bởi vì những giá trị này 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 phép modulo (mở trong tab mới) với 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).

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

Chỉ định hash1 và hash2 làm các biến có thể thay đổi và ghi từng byte của mã băm vào chúng.

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

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

  • Nếu chữ ký không hợp lệ, lời gọi trả về lỗi ở lệnh assert và chương trình bị hủy.
  • Mặc dù khóa công khai có thể được phục hồi từ chữ ký và mã băm, nhưng đâ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 kiến thức. Nếu ai đó cố gắng lừa chúng ta ở đây, việc xác minh chữ ký sẽ thất bại.

Cuối cùng, chúng ta đạt tới 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ệ mã băm của các tài khoản từ giá trị cũ sang giá trị mới. Chúng ta cũng cần chứng minh rằng nó có mã 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ý.

{
    let mut txn = readTransferTxn(message);

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

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

Trong giai đoạn thứ hai, chúng ta 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 cách 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.

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

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

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

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

    cd client
    npm run dev
    
  5. Duyệt đến mã máy khách tại http://localhost:5173 (mở trong tab mới)

  6. Trước khi bạn có thể đưa ra một giao dịch, bạn cần biết nonce, cũng như số lượng mà bạn có thể gửi. Để có được thông tin này, hãy nhấp vào Update account data (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 huống tiến thoái lưỡng nan ở đây. Một mặt, chúng ta không muốn ký một thông điệp có thể được tái sử dụng (một cuộc tấn công phát lại (mở trong tab mới)), đó là lý do tại sao chúng ta muốn có 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 bên, 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 ta ký một giá trị thay đổi mỗi phút. Điều này có nghĩa là khoảng thời gian rủi ro trước các cuộc tấn công phát lại của chúng ta dài nhất là một phút. Xét rằng trong môi trường 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 có thể chấp nhận được.

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

  8. Để xem chuyển khoản, chọn Update account data (Cập nhật dữ liệu tài khoản) hoặc nhìn vào cửa sổ nơi bạn chạy máy chủ. Máy chủ ghi nhật ký trạng thái mỗi khi nó thay đổi.

server/index.mjs

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

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

Thư viện noir.js (mở trong tab mới) giao tiếp giữa mã JavaScript và mã Noir.

const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))
const 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ó.

// Chúng tôi chỉ cung cấp thông tin tài khoản để đáp lại một yêu cầu đã ký
const accountInformation = async signature => {
    const fromAddress = await recoverAddress({
        hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
        signature
    })

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

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

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

    // Lấy khóa công khai
    const pubKey = await recoverPublicKey({
        hash,
        signature
    })

Bây giờ khi 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 máy khách.

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 (mở trong tab mới). 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 dưới dạng một giá trị thập lục phân duy nhất (0x60A7), như cách Viem thực hiện.

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

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 đến máy khách.

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

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

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

Cấu trúc Accounts ban đầu.

Giai đoạn 3 - Các hợp đồng thông minh Ethereum

  1. Dừng các quá trình máy chủ và máy khách.

  2. Tải xuống nhánh với cá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.

    git checkout 03-smart-contracts
    cd client
    npm install
    cd ../server
    npm 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.

    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. Đi tới 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.

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

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

    Giá trị 0x199..67b là mã băm Pederson của trạng thái ban đầu của Accounts. 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 mã băm ban đầu được báo cáo bởi bằng chứng không kiến thức.

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

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

    cd client
    npm run dev
    
  10. Chạy một vài 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 quá trình máy chủ. Bạn sẽ thấy rằng ZkBank không còn chấp nhận các giao dịch nữa, 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 được dự đoán trước.

server/index.mjs

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

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

const execPromise = util.promisify(exec)

Chúng ta cần sử dụng gói Barretenberg (mở trong tab mới) để tạo ra 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 (mở trong tab mới). Thư viện JavaScript chậm hơn nhiều so với việc chạy mã một cách nguyên bản, vì vậy chúng ta sử dụng exec (mở trong tab mới) ở đây để sử dụng giao diện 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 một 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.

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

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

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

Khóa riêng tư này là một trong các tài khoản được cấp vốn sẵn mặc định trong anvil.

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

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

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

Ghi bằng chứng dữ liệu vào một tệp.

    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 một tệp chứa các biến công khai, nhưng chúng ta không cần nó. Chúng ta đã lấy các biến đó từ noir.execute.

    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 chuỗi 0x, và sau đó thêm một chuỗi đó vào cuối.

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

    return proof
}

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

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

    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 ta cần chia mã băm giao dịch thành hai giá trị Field, nó xuất hiện dưới dạng một giá trị 16 byte. Ở đây chúng ta thêm các số không để Viem hiểu nó thực sự là 32 byte.

    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 fromAddress và nonce làm số nhận dạng duy nhất cho tệp bằng chứng dữ liệu và thư mục đầu ra.

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.

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à mã băm trạng thái hiện tại.

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

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

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

Hàm này xử lý các giao dịch. Nó lấy bằng chứng (dưới dạng bytes) và các đầu vào công khai (dưới dạng mảng bytes32), ở đị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 đó giảm chi phí Gas).

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

Bằng chứng không kiến thức cần chứng minh rằng giao dịch thay đổi từ mã băm hiện tại của chúng ta sang một mã băm mới.

        myVerifier.verify(_proof, _publicFields);

Gọi hợp đồng trình xác minh để xác minh bằng chứng không kiến thức. Bước này hoàn nguyên (revert) giao dịch nếu bằng chứng không kiến thức bị sai.

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

Các hành vi lạm dụng của 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 (Confidentiality), 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 (Integrity), thông tin không thể bị thay đổi trừ khi bởi những người dùng được ủy quyền theo cách thức được ủy quyền.
  • Tính khả dụng (Availability), nhữ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 các bằng chứng không kiến thức. Tính khả dụng khó đảm bảo hơn nhiều và tính bảo mật là không thể, bởi vì ngân hàng phải biết số dư của từng 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ảo mật bằng cách sử dụng các địa chỉ ẩn danh (stealth address) (mở trong tab mới), 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 lệch khi dữ liệu được yêu cầu (mở trong tab mới).

Để 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ỉ đó, cùng với mã 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 ta không muốn đăng các nonce và số dư trên chuỗi. Tuy nhiên, nó có thể được xác minh bởi mã máy khách chạy trong trình duyệt.

Các giao dịch bị 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 các L2 là các giao dịch bị bắt buộc (mở trong tab mới). Nhưng các giao dịch bị bắt buộc không kết hợp được với các bằng chứng không kiến thức. Máy chủ là thực thể duy nhất có thể xác minh các giao dịch.

Chúng ta có thể sửa đổi smart-contracts/src/ZkBank.sol để chấp nhận các giao dịch bị 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 lỗ hổng cho một cuộc tấn công từ chối dịch vụ đơn giản. Sẽ thế nào nếu một giao dịch bị 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 kiến thức rằng một giao dịch bị bắt buộc là không hợp lệ. Điều này cung cấp cho máy chủ ba tùy chọn:

  • Xử lý giao dịch bị bắt buộc, cung cấp bằng chứng không kiến thức rằng nó đã được xử lý và mã băm trạng thái mới.
  • Từ chối giao dịch bị bắt buộc, và cung cấp bằng chứng không kiến thức cho hợp đồng rằng giao dịch không hợp lệ (địa chỉ không xác định, nonce sai, hoặc không đủ số dư).
  • Bỏ qua giao dịch bị 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 nó có nghĩa là toàn bộ hệ thống không khả dụng.

Trái phiếu khả dụng

Trong quá trình triển khai thực tế, có thể sẽ có một số động cơ lợi nhuận để giữ cho máy chủ hoạt động. Chúng ta có thể củng cố ưu đãi 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 (burn) nếu một giao dịch bị 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 một hợp đồng thông minh, chúng ta tải mã nguồn lên trình khám phá khối (mở trong tab mới). Tuy nhiên, trong trường hợp các bằng chứng không kiến thức, điều đó là không đủ.

Verifier.sol chứa khóa xác minh, là một chức năng của chương trình Noir. Tuy nhiên, khóa đó không cho chúng ta 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 kiến thức có thể phản ánh một chương trình khác, một chương trình có cửa hậu (back door).

Cho đến khi các trình khám phá khối bắt đầu cho phép chúng ta 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à lên IPFS). Sau đó, những người dùng am hiểu công nghệ 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 kiểu 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, bù 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 bản thân chuỗi khối. Với các bằng chứng không kiến thức, chúng ta có thể đảm bảo tính toàn vẹn và có khả năng tạo ra lợi thế kinh tế cho bất kỳ ai đang điều hành thành phần tập trung để duy trì tính khả dụng.

Xem thêm các tác phẩm của tôi tại đây (mở trong tab mới).

Lời cảm ơn

  • Josh Crites đã đọc 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 đề Noir hóc búa.

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