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

Sử dụng không kiến thức cho một trạng thái bí mật

máy chủ
ngoài chuỗi
tập trung
không tiết lộ thông tin
zokrates
mud
Nâng cao
Ori Pomerantz
15 tháng 3, 2025
36 số phút đọc

Không có bí mật nào trên chuỗi khối. Mọi thứ được đăng trên chuỗi khối đều công khai cho mọi người đọc. Điều này là cần thiết, bởi vì chuỗi khối dựa trên việc bất kỳ ai cũng có thể xác minh nó. Tuy nhiên, các trò chơi thường dựa vào trạng thái bí mật. Ví dụ, trò chơi dò mìn (opens in a new tab) hoàn toàn vô nghĩa nếu bạn chỉ có thể vào một trình duyệt khối và xem bản đồ.

Giải pháp đơn giản nhất là sử dụng một thành phần máy chủ để giữ trạng thái bí mật. Tuy nhiên, lý do chúng ta sử dụng chuỗi khối là để ngăn chặn việc gian lận bởi nhà phát triển trò chơi. Chúng ta cần đảm bảo tính trung thực của thành phần máy chủ. Máy chủ có thể cung cấp một hàm băm của trạng thái, và sử dụng bằng chứng không kiến thức để chứng minh rằng trạng thái được sử dụng để tính toán kết quả của một nước đi là đúng.

Sau khi đọc bài viết này, bạn sẽ biết cách tạo ra loại máy chủ giữ trạng thái bí mật này, một ứng dụng để hiển thị trạng thái và một thành phần trên chuỗi để giao tiếp giữa hai bên. Các công cụ chính chúng tôi sẽ sử dụng là:

Công cụMục đíchĐã xác minh trên phiên bản
Zokrates (opens in a new tab)Bằng chứng không kiến thức và việc xác minh chúng1.1.9
Typescript (opens in a new tab)Ngôn ngữ lập trình cho cả máy chủ và ứng dụng5.4.2
Node (opens in a new tab)Chạy máy chủ20.18.2
Viem (opens in a new tab)Giao tiếp với Chuỗi khối2.9.20
MUD (opens in a new tab)Quản lý dữ liệu trên chuỗi2.0.12
React (opens in a new tab)Giao diện người dùng ứng dụng18.2.0
Vite (opens in a new tab)Phục vụ mã ứng dụng4.2.1

Ví dụ về Dò mìn

Dò mìn (opens in a new tab) là một trò chơi có bản đồ bí mật với một bãi mìn. Người chơi chọn đào ở một vị trí cụ thể. Nếu vị trí đó có mìn, trò chơi kết thúc. Nếu không, người chơi sẽ nhận được số lượng mìn trong tám ô xung quanh vị trí đó.

Ứng dụng này được viết bằng MUD (opens in a new tab), một framework cho phép chúng ta lưu trữ dữ liệu trên chuỗi bằng cách sử dụng cơ sở dữ liệu khóa-giá trị (opens in a new tab) và tự động đồng bộ hóa dữ liệu đó với các thành phần ngoài chuỗi. Ngoài việc đồng bộ hóa, MUD giúp dễ dàng cung cấp kiểm soát truy cập và cho phép những người dùng khác mở rộng (opens in a new tab) ứng dụng của chúng tôi một cách không cần cấp phép.

Chạy ví dụ dò mìn

Để chạy ví dụ dò mìn:

  1. Hãy chắc chắn rằng bạn đã cài đặt các điều kiện tiên quyết (opens in a new tab): Node (opens in a new tab), Foundry (opens in a new tab), git (opens in a new tab), pnpm (opens in a new tab), và mprocs (opens in a new tab).

  2. Sao chép kho lưu trữ.

    1git clone https://github.com/qbzzt/20240901-secret-state.git
  3. Cài đặt các gói.

    1cd 20240901-secret-state/
    2pnpm install
    3npm install -g mprocs

    Nếu Foundry được cài đặt như một phần của pnpm install, bạn cần khởi động lại shell dòng lệnh.

  4. Biên dịch các hợp đồng

    1cd packages/contracts
    2forge build
    3cd ../..
  5. Khởi động chương trình (bao gồm một chuỗi khối anvil (opens in a new tab)) và chờ đợi.

    1mprocs

    Lưu ý rằng quá trình khởi động mất nhiều thời gian. Để xem tiến trình, trước tiên hãy sử dụng phím mũi tên xuống để cuộn đến tab contracts để xem các hợp đồng MUD đang được triển khai. Khi bạn nhận được thông báo Waiting for file changes…, các hợp đồng đã được triển khai và tiến trình tiếp theo sẽ diễn ra trong tab server. Ở đó, bạn đợi cho đến khi nhận được thông báo Verifier address: 0x.....

    Nếu bước này thành công, bạn sẽ thấy màn hình mprocs, với các quy trình khác nhau ở bên trái và đầu ra của bảng điều khiển cho quy trình hiện được chọn ở bên phải.

    Màn hình mprocs

    Nếu có vấn đề với mprocs, bạn có thể chạy bốn quy trình theo cách thủ công, mỗi quy trình trong cửa sổ dòng lệnh riêng của nó:

    • Anvil

      1cd packages/contracts
      2anvil --base-fee 0 --block-time 2
    • Các hợp đồng

      1cd packages/contracts
      2pnpm mud dev-contracts --rpc http://127.0.0.1:8545
    • Máy chủ

      1cd packages/server
      2pnpm start
    • Ứng dụng

      1cd packages/client
      2pnpm run dev
  6. Bây giờ bạn có thể duyệt đến ứng dụng (opens in a new tab), nhấp vào New Game, và bắt đầu chơi.

Bảng

Chúng ta cần một vài bảng (opens in a new tab) trên chuỗi.

  • Configuration: Bảng này là một singleton, nó không có khóa và chỉ có một bản ghi duy nhất. Nó được dùng để chứa thông tin cấu hình trò chơi:

    • height: Chiều cao của một bãi mìn
    • width: Chiều rộng của một bãi mìn
    • numberOfBombs: Số lượng bom trong mỗi bãi mìn
  • VerifierAddress: Bảng này cũng là một singleton. Nó được sử dụng để giữ một phần của cấu hình, địa chỉ của hợp đồng xác minh (verifier). Chúng ta có thể đã đặt thông tin này trong bảng Configuration, nhưng nó được thiết lập bởi một thành phần khác, máy chủ, vì vậy việc đặt nó trong một bảng riêng sẽ dễ dàng hơn.

  • PlayerGame: Khóa là địa chỉ của người chơi. Dữ liệu là:

    • gameId: giá trị 32 byte là hàm băm của bản đồ mà người chơi đang chơi (định danh trò chơi).
    • win: một giá trị boolean cho biết liệu người chơi đã thắng trò chơi hay chưa.
    • lose: một giá trị boolean cho biết liệu người chơi đã thua trò chơi hay chưa.
    • digNumber: số lần đào thành công trong trò chơi.
  • GamePlayer: Bảng này giữ ánh xạ ngược, từ gameId đến địa chỉ người chơi.

  • Map: Khóa là một bộ ba giá trị:

    • gameId: giá trị 32 byte là hàm băm của bản đồ mà người chơi đang chơi (định danh trò chơi).
    • tọa độ x
    • tọa độ y

    Giá trị là một số duy nhất. Nó là 255 nếu một quả bom được phát hiện. Nếu không, nó là số lượng bom xung quanh vị trí đó cộng một. Chúng tôi không thể chỉ sử dụng số lượng bom, bởi vì theo mặc định, tất cả bộ nhớ trong máy ảo Ethereum và tất cả các giá trị hàng trong MUD đều bằng không. Chúng ta cần phân biệt giữa "người chơi chưa đào ở đây" và "người chơi đã đào ở đây, và thấy có không có quả bom nào xung quanh".

Ngoài ra, việc giao tiếp giữa ứng dụng và máy chủ diễn ra thông qua thành phần trên chuỗi. Điều này cũng được thực hiện bằng cách sử dụng các bảng.

  • PendingGame: Các yêu cầu chưa được phục vụ để bắt đầu một trò chơi mới.
  • PendingDig: Các yêu cầu chưa được phục vụ để đào tại một vị trí cụ thể trong một trò chơi cụ thể. Đây là một bảng ngoài chuỗi (opens in a new tab), có nghĩa là nó không được ghi vào bộ nhớ máy ảo Ethereum, nó chỉ có thể được đọc ngoài chuỗi bằng các sự kiện.

Luồng thực thi và dữ liệu

Các luồng này điều phối việc thực thi giữa ứng dụng, thành phần trên chuỗi và máy chủ.

Khởi tạo

Khi bạn chạy mprocs, các bước sau sẽ xảy ra:

  1. mprocs (opens in a new tab) chạy bốn thành phần:

  2. Gói contracts triển khai các hợp đồng MUD và sau đó chạy tập lệnh PostDeploy.s.sol (opens in a new tab). Tập lệnh này thiết lập cấu hình. Mã từ github chỉ định một bãi mìn 10x5 với tám quả mìn trong đó (opens in a new tab).

  3. Máy chủ (opens in a new tab) bắt đầu bằng cách thiết lập MUD (opens in a new tab). Trong số những thứ khác, điều này kích hoạt đồng bộ hóa dữ liệu, để một bản sao của các bảng liên quan tồn tại trong bộ nhớ của máy chủ.

  4. Máy chủ đăng ký một hàm để được thực thi khi bảng Configuration thay đổi (opens in a new tab). Hàm này (opens in a new tab) được gọi sau khi PostDeploy.s.sol thực thi và sửa đổi bảng.

  5. Khi hàm khởi tạo máy chủ có cấu hình, nó gọi zkFunctions (opens in a new tab) để khởi tạo phần không kiến thức của máy chủ. Điều này không thể xảy ra cho đến khi chúng ta nhận được cấu hình vì các hàm không kiến thức phải có chiều rộng và chiều cao của bãi mìn làm hằng số.

  6. Sau khi phần không kiến thức của máy chủ được khởi tạo, bước tiếp theo là triển khai hợp đồng xác minh không kiến thức lên chuỗi khối (opens in a new tab) và đặt địa chỉ của người được xác minh trong MUD.

  7. Cuối cùng, chúng tôi đăng ký các bản cập nhật để chúng tôi sẽ thấy khi người chơi yêu cầu bắt đầu một trò chơi mới (opens in a new tab) hoặc đào trong một trò chơi hiện có (opens in a new tab).

Trò chơi mới

Đây là những gì xảy ra khi người chơi yêu cầu một trò chơi mới.

  1. Nếu không có trò chơi nào đang diễn ra cho người chơi này, hoặc có một trò chơi nhưng với gameId bằng không, ứng dụng sẽ hiển thị một nút trò chơi mới (opens in a new tab). Khi người dùng nhấn nút này, React sẽ chạy hàm newGame (opens in a new tab).

  2. newGame (opens in a new tab) là một lệnh gọi System. Trong MUD, tất cả các lệnh gọi đều được định tuyến thông qua hợp đồng World, và trong hầu hết các trường hợp, bạn gọi <namespace>__<tên hàm>. Trong trường hợp này, lệnh gọi là app__newGame, mà MUD sau đó sẽ định tuyến đến newGame trong GameSystem (opens in a new tab).

  3. Hàm trên chuỗi kiểm tra xem người chơi có đang trong một trò chơi hay không, và nếu không có, nó sẽ thêm yêu cầu vào bảng PendingGame (opens in a new tab).

  4. Máy chủ phát hiện thay đổi trong PendingGamechạy hàm đã đăng ký (opens in a new tab). Hàm này gọi newGame (opens in a new tab), và hàm này lại gọi createGame (opens in a new tab).

  5. Điều đầu tiên createGame làm là tạo một bản đồ ngẫu nhiên với số lượng mìn phù hợp (opens in a new tab). Sau đó, nó gọi makeMapBorders (opens in a new tab) để tạo một bản đồ với các đường viền trống, điều này là cần thiết cho Zokrates. Cuối cùng, createGame gọi calculateMapHash, để lấy hàm băm của bản đồ, được sử dụng làm ID trò chơi.

  6. Hàm newGame thêm trò chơi mới vào gamesInProgress.

  7. Điều cuối cùng máy chủ làm là gọi app__newGameResponse (opens in a new tab), một hàm trên chuỗi. Hàm này nằm trong một System khác, ServerSystem (opens in a new tab), để cho phép kiểm soát truy cập. Kiểm soát truy cập được định nghĩa trong tệp cấu hình MUD (opens in a new tab), mud.config.ts (opens in a new tab).

    Danh sách truy cập chỉ cho phép một địa chỉ duy nhất gọi System. Điều này hạn chế quyền truy cập vào các chức năng của máy chủ cho một địa chỉ duy nhất, vì vậy không ai có thể mạo danh máy chủ.

  8. Thành phần trên chuỗi cập nhật các bảng liên quan:

    • Tạo trò chơi trong PlayerGame.
    • Thiết lập ánh xạ ngược trong GamePlayer.
    • Xóa yêu cầu khỏi PendingGame.
  9. Máy chủ xác định sự thay đổi trong PendingGame, nhưng không làm gì vì wantsGame (opens in a new tab) là false.

  10. Trên ứng dụng gameRecord (opens in a new tab) được đặt thành mục PlayerGame cho địa chỉ của người chơi. Khi PlayerGame thay đổi, gameRecord cũng thay đổi.

  11. Nếu có giá trị trong gameRecord, và trò chơi chưa thắng hay thua, ứng dụng sẽ hiển thị bản đồ (opens in a new tab).

Đào

  1. Người chơi nhấp vào nút của ô trên bản đồ (opens in a new tab), hành động này sẽ gọi hàm dig (opens in a new tab). Hàm này gọi dig trên chuỗi (opens in a new tab).

  2. Thành phần trên chuỗi thực hiện một số kiểm tra tính hợp lệ (opens in a new tab), và nếu thành công, nó sẽ thêm yêu cầu đào vào PendingDig (opens in a new tab).

  3. Máy chủ phát hiện sự thay đổi trong PendingDig (opens in a new tab). Nếu nó hợp lệ (opens in a new tab), nó sẽ gọi mã không kiến thức (opens in a new tab) (được giải thích bên dưới) để tạo ra cả kết quả và một bằng chứng rằng nó hợp lệ.

  4. Máy chủ (opens in a new tab) gọi digResponse (opens in a new tab) trên chuỗi.

  5. digResponse thực hiện hai việc. Đầu tiên, nó kiểm tra bằng chứng không kiến thức (opens in a new tab). Sau đó, nếu bằng chứng được xác minh, nó sẽ gọi processDigResult (opens in a new tab) để thực sự xử lý kết quả.

  6. processDigResult kiểm tra xem trò chơi đã thua (opens in a new tab) hay thắng (opens in a new tab), và cập nhật Map, bản đồ trên chuỗi (opens in a new tab).

  7. Ứng dụng tự động nhận các bản cập nhật và cập nhật bản đồ hiển thị cho người chơi (opens in a new tab), và nếu có thể, thông báo cho người chơi biết họ thắng hay thua.

Sử dụng Zokrates

Trong các luồng đã giải thích ở trên, chúng ta đã bỏ qua các phần không kiến thức, coi chúng như một hộp đen. Bây giờ hãy mở nó ra và xem mã đó được viết như thế nào.

Băm bản đồ

Chúng ta có thể sử dụng mã JavaScript này (opens in a new tab) để triển khai Poseidon (opens in a new tab), hàm băm Zokrates mà chúng ta sử dụng. Tuy nhiên, mặc dù cách này sẽ nhanh hơn, nhưng nó cũng sẽ phức tạp hơn so với việc chỉ sử dụng hàm băm Zokrates để thực hiện. Đây là một hướng dẫn, vì vậy mã được tối ưu hóa cho sự đơn giản, không phải cho hiệu suất. Do đó, chúng ta cần hai chương trình Zokrates khác nhau, một chương trình chỉ để tính toán hàm băm của một bản đồ (hash) và một chương trình khác để thực sự tạo ra một bằng chứng không kiến thức về kết quả của việc đào tại một vị trí trên bản đồ (dig).

Hàm băm

Đây là hàm tính toán hàm băm của một bản đồ. Chúng ta sẽ xem qua từng dòng mã này.

1import "hashes/poseidon/poseidon.zok" as poseidon;
2import "utils/pack/bool/pack128.zok" as pack128;

Hai dòng này nhập hai hàm từ thư viện chuẩn Zokrates (opens in a new tab). Hàm đầu tiên (opens in a new tab) là một hàm băm Poseidon (opens in a new tab). Nó nhận một mảng các phần tử field (opens in a new tab) và trả về một field.

Phần tử trường trong Zokrates thường nhỏ hơn 256 bit, nhưng không nhiều. Để đơn giản hóa mã, chúng tôi giới hạn bản đồ ở mức tối đa 512 bit và băm một mảng gồm bốn trường, và trong mỗi trường, chúng tôi chỉ sử dụng 128 bit. Hàm pack128 (opens in a new tab) thay đổi một mảng 128 bit thành một field cho mục đích này.

1 def hashMap(bool[${width+2}][${height+2}] map) -> field {

Dòng này bắt đầu định nghĩa một hàm. hashMap nhận một tham số duy nhất có tên là map, một mảng bool(ean) hai chiều. Kích thước của bản đồ là width+2 nhân height+2 vì những lý do được giải thích bên dưới.

Chúng ta có thể sử dụng ${width+2}${height+2} vì các chương trình Zokrates được lưu trữ trong ứng dụng này dưới dạng chuỗi mẫu (opens in a new tab). Mã giữa ${} được đánh giá bởi JavaScript, và bằng cách này, chương trình có thể được sử dụng cho các kích thước bản đồ khác nhau. Tham số bản đồ có một đường viền rộng một vị trí bao quanh nó mà không có quả bom nào, đó là lý do chúng ta cần thêm hai vào chiều rộng và chiều cao.

Giá trị trả về là một field chứa hàm băm.

1 bool[512] mut map1d = [false; 512];

Bản đồ là hai chiều. Tuy nhiên, hàm pack128 không hoạt động với mảng hai chiều. Vì vậy, trước tiên chúng ta làm phẳng bản đồ thành một mảng 512 byte, sử dụng map1d. Theo mặc định, các biến Zokrates là hằng số, nhưng chúng ta cần gán giá trị cho mảng này trong một vòng lặp, vì vậy chúng ta định nghĩa nó là mut (opens in a new tab).

Chúng ta cần khởi tạo mảng vì Zokrates không có undefined. Biểu thức [false; 512] có nghĩa là một mảng gồm 512 giá trị false (opens in a new tab).

1 u32 mut counter = 0;

Chúng ta cũng cần một bộ đếm để phân biệt giữa các bit chúng ta đã điền vào map1d và những bit chưa điền.

1 for u32 x in 0..${width+2} {

Đây là cách bạn khai báo một vòng lặp for (opens in a new tab) trong Zokrates. Một vòng lặp for của Zokrates phải có giới hạn cố định, bởi vì mặc dù nó có vẻ là một vòng lặp, trình biên dịch thực sự "mở rộng" nó. Biểu thức ${width+2} là một hằng số tại thời điểm biên dịch vì width được thiết lập bởi mã TypeScript trước khi nó gọi trình biên dịch.

1 for u32 y in 0..${height+2} {
2 map1d[counter] = map[x][y];
3 counter = counter+1;
4 }
5 }

Đối với mỗi vị trí trong bản đồ, đặt giá trị đó vào mảng map1d và tăng bộ đếm.

1 field[4] hashMe = [
2 pack128(map1d[0..128]),
3 pack128(map1d[128..256]),
4 pack128(map1d[256..384]),
5 pack128(map1d[384..512])
6 ];

pack128 để tạo một mảng gồm bốn giá trị field từ map1d. Trong Zokrates, array[a..b] có nghĩa là lát cắt của mảng bắt đầu tại a và kết thúc tại b-1.

1 return poseidon(hashMe);
2}

Sử dụng poseidon để chuyển đổi mảng này thành một hàm băm.

Chương trình băm

Máy chủ cần gọi hashMap trực tiếp để tạo định danh trò chơi. Tuy nhiên, Zokrates chỉ có thể gọi hàm main trên một chương trình để bắt đầu, vì vậy chúng tôi tạo một chương trình với một main gọi hàm băm.

1${hashFragment}
2
3def main(bool[${width+2}][${height+2}] map) -> field {
4 return hashMap(map);
5}

Chương trình đào

Đây là trung tâm của phần không kiến thức của ứng dụng, nơi chúng tôi tạo ra các bằng chứng được sử dụng để xác minh kết quả đào.

1${hashFragment}
2
3// Số lượng mìn tại vị trí (x,y)
4def map2mineCount(bool[${width+2}][${height+2}] map, u32 x, u32 y) -> u8 {
5 return if map[x+1][y+1] { 1 } else { 0 };
6}

Tại sao lại có đường viền bản đồ

Bằng chứng không kiến thức sử dụng mạch số học (opens in a new tab), không có cách tương đương dễ dàng với một câu lệnh if. Thay vào đó, chúng sử dụng tương đương của toán tử điều kiện (opens in a new tab). Nếu a có thể là không hoặc một, bạn có thể tính if a { b } else { c }ab+(1-a)c.

Vì điều này, một câu lệnh if của Zokrates luôn đánh giá cả hai nhánh. Ví dụ, nếu bạn có mã này:

1bool[5] arr = [false; 5];
2u32 index=10;
3return if index>4 { 0 } else { arr[index] }

Nó sẽ báo lỗi, bởi vì nó cần tính toán arr[10], mặc dù giá trị đó sau đó sẽ được nhân với không.

Đây là lý do chúng ta cần một đường viền rộng một vị trí bao quanh bản đồ. Chúng ta cần tính tổng số mìn xung quanh một vị trí, và điều đó có nghĩa là chúng ta cần xem vị trí ở hàng trên và dưới, bên trái và bên phải của vị trí chúng ta đang đào. Điều đó có nghĩa là những vị trí đó phải tồn tại trong mảng bản đồ mà Zokrates được cung cấp.

1def main(private bool[${width+2}][${height+2}] map, u32 x, u32 y) -> (field, u8) {

Theo mặc định, các bằng chứng Zokrates bao gồm các đầu vào của chúng. Sẽ không có ích gì khi biết có năm quả mìn xung quanh một điểm trừ khi bạn thực sự biết đó là điểm nào (và bạn không thể chỉ khớp nó với yêu cầu của mình, bởi vì khi đó người chứng minh có thể sử dụng các giá trị khác nhau và không cho bạn biết về điều đó). Tuy nhiên, chúng ta cần giữ bí mật bản đồ, trong khi cung cấp nó cho Zokrates. Giải pháp là sử dụng một tham số private, một tham số không được tiết lộ bởi bằng chứng.

Điều này mở ra một con đường khác để lạm dụng. Người chứng minh có thể sử dụng các tọa độ chính xác, nhưng tạo ra một bản đồ với bất kỳ số lượng mìn nào xung quanh vị trí đó, và có thể cả tại chính vị trí đó. Để ngăn chặn sự lạm dụng này, chúng tôi làm cho bằng chứng không kiến thức bao gồm cả hàm băm của bản đồ, đó là định danh trò chơi.

1 return (hashMap(map),

Giá trị trả về ở đây là một tuple bao gồm mảng băm bản đồ cũng như kết quả đào.

1 if map2mineCount(map, x, y) > 0 { 0xFF } else {

Chúng tôi sử dụng 255 làm giá trị đặc biệt trong trường hợp chính vị trí đó có bom.

1 map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
2 map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
3 map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
4 }
5 );
6}

Nếu người chơi không trúng mìn, hãy cộng số lượng mìn cho khu vực xung quanh vị trí đó và trả về kết quả đó.

Sử dụng Zokrates từ TypeScript

Zokrates có một giao diện dòng lệnh, nhưng trong chương trình này chúng ta sử dụng nó trong mã TypeScript (opens in a new tab).

Thư viện chứa các định nghĩa Zokrates được gọi là zero-knowledge.ts (opens in a new tab).

1import { initialize as zokratesInitialize } from "zokrates-js"

Nhập các ràng buộc JavaScript của Zokrates (opens in a new tab). Chúng ta chỉ cần hàm initialize (opens in a new tab) vì nó trả về một promise giải quyết thành tất cả các định nghĩa của Zokrates.

1export const zkFunctions = async (width: number, height: number) : Promise<any> => {

Tương tự như chính Zokrates, chúng tôi cũng chỉ xuất một hàm, cũng là bất đồng bộ (opens in a new tab). Khi cuối cùng nó trả về, nó cung cấp một số hàm như chúng ta sẽ thấy bên dưới.

1const zokrates = await zokratesInitialize()

Khởi tạo Zokrates, nhận mọi thứ chúng ta cần từ thư viện.

1const hashFragment = `
2 import "utils/pack/bool/pack128.zok" as pack128;
3 import "hashes/poseidon/poseidon.zok" as poseidon;
4 .
5 .
6 .
7 }
8 `
9
10const hashProgram = `
11 ${hashFragment}
12 .
13 .
14 .
15 `
16
17const digProgram = `
18 ${hashFragment}
19 .
20 .
21 .
22 `
Hiện tất cả

Tiếp theo, chúng ta có hàm băm và hai chương trình Zokrates mà chúng ta đã thấy ở trên.

1const digCompiled = zokrates.compile(digProgram)
2const hashCompiled = zokrates.compile(hashProgram)

Ở đây chúng ta biên dịch các chương trình đó.

1// Tạo các khóa để xác minh không kiến thức.
2// Trên một hệ thống sản xuất, bạn sẽ muốn sử dụng một nghi lễ thiết lập.
3// (https://zokrates.github.io/toolbox/trusted_setup.html#initializing-a-phase-2-ceremony).
4const keySetupResults = zokrates.setup(digCompiled.program, "")
5const verifierKey = keySetupResults.vk
6const proverKey = keySetupResults.pk

Trên một hệ thống sản xuất, chúng ta có thể sử dụng một nghi lễ thiết lập (opens in a new tab) phức tạp hơn, nhưng điều này đủ tốt cho một bản demo. Không thành vấn đề khi người dùng có thể biết khóa của người chứng minh - họ vẫn không thể sử dụng nó để chứng minh những điều không đúng sự thật. Bởi vì chúng ta chỉ định entropy (tham số thứ hai, ""), kết quả sẽ luôn giống nhau.

Lưu ý: Việc biên dịch các chương trình Zokrates và tạo khóa là những quá trình chậm. Không cần phải lặp lại chúng mỗi lần, chỉ khi kích thước bản đồ thay đổi. Trên một hệ thống sản xuất, bạn sẽ thực hiện chúng một lần, và sau đó lưu trữ đầu ra. Lý do duy nhất tôi không làm điều đó ở đây là vì sự đơn giản.

calculateMapHash

1const calculateMapHash = function (hashMe: boolean[][]): string {
2 return (
3 "0x" +
4 BigInt(zokrates.computeWitness(hashCompiled, [hashMe]).output.slice(1, -1))
5 .toString(16)
6 .padStart(64, "0")
7 )
8}

Hàm computeWitness (opens in a new tab) thực sự chạy chương trình Zokrates. Nó trả về một cấu trúc với hai trường: output, là đầu ra của chương trình dưới dạng chuỗi JSON, và witness, là thông tin cần thiết để tạo ra một bằng chứng không kiến thức của kết quả. Ở đây chúng ta chỉ cần đầu ra.

Đầu ra là một chuỗi có dạng "31337", một số thập phân được đặt trong dấu ngoặc kép. Nhưng đầu ra chúng ta cần cho viem là một số thập lục phân có dạng 0x60A7. Vì vậy, chúng ta sử dụng .slice(1,-1) để loại bỏ dấu ngoặc kép và sau đó BigInt để chạy chuỗi còn lại, là một số thập phân, thành một BigInt (opens in a new tab). .toString(16) chuyển đổi BigInt này thành một chuỗi thập lục phân, và "0x"+ thêm dấu hiệu cho các số thập lục phân.

1// Đào và trả về một bằng chứng không kiến thức của kết quả
2// (mã phía máy chủ)

Bằng chứng không kiến thức bao gồm các đầu vào công khai (xy) và kết quả (hàm băm của bản đồ và số lượng bom).

1 const zkDig = function(map: boolean[][], x: number, y: number) : any {
2 if (x<0 || x>=width || y<0 || y>=height)
3 throw new Error("Trying to dig outside the map")

Kiểm tra xem một chỉ số có nằm ngoài giới hạn trong Zokrates hay không là một vấn đề, vì vậy chúng ta thực hiện nó ở đây.

1const runResults = zokrates.computeWitness(digCompiled, [map, `${x}`, `${y}`])

Thực thi chương trình đào.

1 const proof = zokrates.generateProof(
2 digCompiled.program,
3 runResults.witness,
4 proverKey)
5
6 return proof
7 }

Sử dụng generateProof (opens in a new tab) và trả về bằng chứng.

1const solidityVerifier = `
2 // Kích thước bản đồ: ${width} x ${height}
3 \n${zokrates.exportSolidityVerifier(verifierKey)}
4 `

Một trình xác minh Solidity, một hợp đồng thông minh mà chúng ta có thể triển khai lên chuỗi khối và sử dụng để xác minh các bằng chứng được tạo ra bởi digCompiled.program.

1 return {
2 zkDig,
3 calculateMapHash,
4 solidityVerifier,
5 }
6}

Cuối cùng, trả về mọi thứ mà mã khác có thể cần.

Kiểm tra bảo mật

Kiểm tra bảo mật là quan trọng vì một lỗi chức năng cuối cùng sẽ tự lộ ra. Nhưng nếu ứng dụng không an toàn, điều đó có khả năng sẽ bị che giấu trong một thời gian dài trước khi nó bị tiết lộ bởi một người gian lận và lấy đi các tài nguyên thuộc về người khác.

Quyền

Có một thực thể có đặc quyền trong trò chơi này, đó là máy chủ. Đó là người dùng duy nhất được phép gọi các hàm trong ServerSystem (opens in a new tab). Chúng ta có thể sử dụng cast (opens in a new tab) để xác minh rằng các lệnh gọi đến các hàm có quyền chỉ được phép khi là tài khoản máy chủ.

Khóa riêng tư của máy chủ nằm trong setupNetwork.ts (opens in a new tab).

  1. Trên máy tính chạy anvil (chuỗi khối), hãy đặt các biến môi trường này.

    1WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b
    2UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
    3AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
  2. Sử dụng cast để cố gắng đặt địa chỉ trình xác minh là một địa chỉ không được ủy quyền.

    1cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEY

    Không chỉ cast báo cáo lỗi, mà bạn có thể mở MUD Dev Tools trong trò chơi trên trình duyệt, nhấp vào Tables, và chọn app__VerifierAddress. Xem rằng địa chỉ không phải là không.

  3. Đặt địa chỉ trình xác minh là địa chỉ của máy chủ.

    1cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $AUTHORIZED_KEY

    Địa chỉ trong app__VerifiedAddress bây giờ phải là không.

Tất cả các hàm MUD trong cùng một System đều đi qua cùng một kiểm soát truy cập, vì vậy tôi cho rằng thử nghiệm này là đủ. Nếu bạn không nghĩ vậy, bạn có thể kiểm tra các hàm khác trong ServerSystem (opens in a new tab).

Các hành vi lạm dụng không kiến thức

Phần toán học để xác minh Zokrates nằm ngoài phạm vi của hướng dẫn này (và khả năng của tôi). Tuy nhiên, chúng ta có thể chạy các kiểm tra khác nhau trên mã không kiến thức để xác minh rằng nếu nó không được thực hiện đúng cách, nó sẽ thất bại. Tất cả các bài kiểm tra này sẽ yêu cầu chúng ta thay đổi zero-knowledge.ts (opens in a new tab) và khởi động lại toàn bộ ứng dụng. Việc khởi động lại quy trình máy chủ là không đủ, bởi vì nó đặt ứng dụng vào một trạng thái không thể thực hiện (người chơi có một trò chơi đang diễn ra, nhưng trò chơi đó không còn khả dụng cho máy chủ).

Câu trả lời sai

Khả năng đơn giản nhất là cung cấp câu trả lời sai trong bằng chứng không kiến thức. Để làm điều đó, chúng ta vào bên trong zkDigsửa đổi dòng 91 (opens in a new tab):

1proof.inputs[3] = "0x" + "1".padStart(64, "0")

Điều này có nghĩa là chúng ta sẽ luôn tuyên bố có một quả bom, bất kể câu trả lời đúng là gì. Hãy thử chơi với phiên bản này, và bạn sẽ thấy trong tab server của màn hình pnpm dev có lỗi này:

1 cause: {
2 code: 3,
3 message: 'execution reverted: revert: Zero knowledge verification fail',
4 data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
5000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
6e206661696c'
7 },

Vì vậy, loại gian lận này thất bại.

Bằng chứng sai

Điều gì sẽ xảy ra nếu chúng ta cung cấp thông tin chính xác, nhưng chỉ có dữ liệu bằng chứng sai? Bây giờ, hãy thay thế dòng 91 bằng:

1proof.proof = {
2 a: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
3 b: [
4 ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
5 ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
6 ],
7 c: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
8}

Nó vẫn thất bại, nhưng bây giờ nó thất bại mà không có lý do vì nó xảy ra trong quá trình gọi trình xác minh.

Làm thế nào một người dùng có thể xác minh mã tin cậy không kiến thức?

Các hợp đồng thông minh tương đối dễ xác minh. Thông thường, nhà phát triển xuất bản mã nguồn lên một trình duyệt khối, và trình duyệt khối xác minh rằng mã nguồn đó thực sự biên dịch thành mã trong giao dịch triển khai hợp đồng. Trong trường hợp của các System MUD, điều này phức tạp hơn một chút (opens in a new tab), nhưng không nhiều.

Điều này khó hơn với không kiến thức. Trình xác minh bao gồm một số hằng số và chạy một số tính toán trên chúng. Điều này không cho bạn biết điều gì đang được chứng minh.

1 function verifyingKey() pure internal returns (VerifyingKey memory vk) {
2 vk.alpha = Pairing.G1Point(uint256(0x0f43f4fe7b5c2326fed4ac6ed2f4003ab9ab4ea6f667c2bdd77afb068617ee16), uint256(0x25a77832283f9726935219b5f4678842cda465631e72dbb24708a97ba5d0ce6f));
3 vk.beta = Pairing.G2Point([uint256(0x2cebd0fbd21aca01910581537b21ae4fed46bc0e524c055059aa164ba0a6b62b), uint256(0x18fd4a7bc386cf03a95af7163d5359165acc4e7961cb46519e6d9ee4a1e2b7e9)], [uint256(0x11449dee0199ef6d8eebfe43b548e875c69e7ce37705ee9a00c81fe52f11a009), uint256(0x066d0c83b32800d3f335bb9e8ed5e2924cf00e77e6ec28178592eac9898e1a00)]);

Giải pháp, ít nhất cho đến khi các trình duyệt khối thêm xác minh Zokrates vào giao diện người dùng của họ, là các nhà phát triển ứng dụng cung cấp các chương trình Zokrates, và ít nhất một số người dùng tự biên dịch chúng với khóa xác minh thích hợp.

Để làm như vậy:

  1. Cài đặt Zokrates (opens in a new tab).

  2. Tạo một tệp, dig.zok, với chương trình Zokrates. Mã dưới đây giả định bạn giữ nguyên kích thước bản đồ ban đầu, 10x5.

    1 import "utils/pack/bool/pack128.zok" as pack128;
    2 import "hashes/poseidon/poseidon.zok" as poseidon;
    3
    4 def hashMap(bool[12][7] map) -> field {
    5 bool[512] mut map1d = [false; 512];
    6 u32 mut counter = 0;
    7
    8 for u32 x in 0..12 {
    9 for u32 y in 0..7 {
    10 map1d[counter] = map[x][y];
    11 counter = counter+1;
    12 }
    13 }
    14
    15 field[4] hashMe = [
    16 pack128(map1d[0..128]),
    17 pack128(map1d[128..256]),
    18 pack128(map1d[256..384]),
    19 pack128(map1d[384..512])
    20 ];
    21
    22 return poseidon(hashMe);
    23 }
    24
    25
    26 // Số lượng mìn tại vị trí (x,y)
    27 def map2mineCount(bool[12][7] map, u32 x, u32 y) -> u8 {
    28 return if map[x+1][y+1] { 1 } else { 0 };
    29 }
    30
    31 def main(private bool[12][7] map, u32 x, u32 y) -> (field, u8) {
    32 return (hashMap(map) ,
    33 if map2mineCount(map, x, y) > 0 { 0xFF } else {
    34 map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
    35 map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
    36 map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
    37 }
    38 );
    39 }
    Hiện tất cả
  3. Biên dịch mã Zokrates và tạo khóa xác minh. Khóa xác minh phải được tạo với cùng một entropy được sử dụng trong máy chủ ban đầu, trong trường hợp này là một chuỗi rỗng (opens in a new tab).

    1zokrates compile --input dig.zok
    2zokrates setup -e ""
  4. Tự tạo trình xác minh Solidity và xác minh rằng nó giống hệt về mặt chức năng với trình xác minh trên chuỗi khối (máy chủ thêm một nhận xét, nhưng điều đó không quan trọng).

    1zokrates export-verifier
    2diff verifier.sol ~/20240901-secret-state/packages/contracts/src/verifier.sol

Các quyết định thiết kế

Trong bất kỳ ứng dụng đủ phức tạp nào, đều có những mục tiêu thiết kế cạnh tranh đòi hỏi sự đánh đổi. Hãy xem xét một số đánh đổi và tại sao giải pháp hiện tại lại được ưu tiên hơn các tùy chọn khác.

Tại sao lại là không kiến thức

Đối với trò chơi dò mìn, bạn không thực sự cần không kiến thức. Máy chủ luôn có thể giữ bản đồ, và sau đó chỉ cần tiết lộ toàn bộ nó khi trò chơi kết thúc. Sau đó, vào cuối trò chơi, hợp đồng thông minh có thể tính toán hàm băm của bản đồ, xác minh rằng nó khớp, và nếu không khớp sẽ phạt máy chủ hoặc bỏ qua hoàn toàn trò chơi.

Tôi không sử dụng giải pháp đơn giản này vì nó chỉ hoạt động cho các trò chơi ngắn với trạng thái kết thúc được xác định rõ ràng. Khi một trò chơi có khả năng vô hạn (chẳng hạn như trường hợp của các thế giới tự trị (opens in a new tab)), bạn cần một giải pháp chứng minh trạng thái mà không tiết lộ nó.

Là một hướng dẫn, bài viết này cần một trò chơi ngắn dễ hiểu, nhưng kỹ thuật này hữu ích nhất cho các trò chơi dài hơn.

Tại sao lại là Zokrates?

Zokrates (opens in a new tab) không phải là thư viện không kiến thức duy nhất có sẵn, nhưng nó tương tự như một ngôn ngữ lập trình thông thường, mệnh lệnh (opens in a new tab) và hỗ trợ các biến boolean.

Đối với ứng dụng của bạn, với các yêu cầu khác nhau, bạn có thể thích sử dụng Circum (opens in a new tab) hoặc Cairo (opens in a new tab).

Khi nào biên dịch Zokrates

Trong chương trình này, chúng tôi biên dịch các chương trình Zokrates mỗi khi máy chủ khởi động (opens in a new tab). Điều này rõ ràng là lãng phí tài nguyên, nhưng đây là một hướng dẫn, được tối ưu hóa cho sự đơn giản.

Nếu tôi đang viết một ứng dụng ở cấp độ sản xuất, tôi sẽ kiểm tra xem tôi có một tệp với các chương trình Zokrates đã biên dịch ở kích thước bãi mìn này không, và nếu có thì sử dụng nó. Điều tương tự cũng đúng đối với việc triển khai một hợp đồng xác minh trên chuỗi.

Tạo khóa xác minh và khóa chứng minh

Tạo khóa (opens in a new tab) là một tính toán thuần túy khác không cần phải thực hiện nhiều hơn một lần cho một kích thước bãi mìn nhất định. Một lần nữa, nó chỉ được thực hiện một lần vì mục đích đơn giản.

Ngoài ra, chúng ta có thể sử dụng một nghi lễ thiết lập (opens in a new tab). Ưu điểm của một nghi lễ thiết lập là bạn cần hoặc entropy hoặc một số kết quả trung gian từ mỗi người tham gia để gian lận trong bằng chứng không kiến thức. Nếu ít nhất một người tham gia nghi lễ trung thực và xóa thông tin đó, các bằng chứng không kiến thức sẽ an toàn trước một số cuộc tấn công nhất định. Tuy nhiên, không có cơ chế nào để xác minh rằng thông tin đã được xóa khỏi mọi nơi. Nếu bằng chứng không kiến thức là cực kỳ quan trọng, bạn muốn tham gia vào nghi lễ thiết lập.

Ở đây chúng ta dựa vào perpetual powers of tau (opens in a new tab), đã có hàng chục người tham gia. Nó có lẽ đủ an toàn, và đơn giản hơn nhiều. Chúng tôi cũng không thêm entropy trong quá trình tạo khóa, điều này giúp người dùng xác minh cấu hình không kiến thức dễ dàng hơn.

Xác minh ở đâu

Chúng ta có thể xác minh các bằng chứng không kiến thức trên chuỗi (tốn gas) hoặc trong ứng dụng (sử dụng verify (opens in a new tab)). Tôi đã chọn cách đầu tiên, vì điều này cho phép bạn xác minh người xác minh một lần và sau đó tin tưởng rằng nó không thay đổi miễn là địa chỉ hợp đồng của nó vẫn giữ nguyên. Nếu việc xác minh được thực hiện trên ứng dụng, bạn sẽ phải xác minh mã bạn nhận được mỗi khi tải xuống ứng dụng.

Ngoài ra, trong khi trò chơi này là một người chơi, rất nhiều trò chơi chuỗi khối là nhiều người chơi. xác minh trên chuỗi có nghĩa là bạn chỉ xác minh bằng chứng không kiến thức một lần. Thực hiện nó trong ứng dụng sẽ yêu cầu mỗi ứng dụng phải xác minh độc lập.

Làm phẳng bản đồ trong TypeScript hay Zokrates?

Nói chung, khi quá trình xử lý có thể được thực hiện trong TypeScript hoặc Zokrates, tốt hơn là nên thực hiện trong TypeScript, nhanh hơn nhiều và không yêu cầu bằng chứng không kiến thức. Đây là lý do, ví dụ, chúng ta không cung cấp cho Zokrates hàm băm và yêu cầu nó xác minh rằng nó là chính xác. Việc băm phải được thực hiện bên trong Zokrates, nhưng sự khớp nối giữa hàm băm được trả về và hàm băm trên chuỗi có thể xảy ra bên ngoài nó.

Tuy nhiên, chúng ta vẫn làm phẳng bản đồ trong Zokrates (opens in a new tab), trong khi chúng ta có thể đã làm điều đó trong TypeScript. Lý do là các lựa chọn khác, theo tôi, tệ hơn.

  • Cung cấp một mảng boolean một chiều cho mã Zokrates, và sử dụng một biểu thức như x*(height+2) +y để lấy bản đồ hai chiều. Điều này sẽ làm cho  (opens in a new tab) phức tạp hơn một chút, vì vậy tôi đã quyết định rằng việc tăng hiệu suất không đáng để làm trong một hướng dẫn.

  • Gửi cho Zokrates cả mảng một chiều và mảng hai chiều. Tuy nhiên, giải pháp này không mang lại cho chúng ta bất cứ điều gì. Mã Zokrates sẽ phải xác minh rằng mảng một chiều mà nó được cung cấp thực sự là biểu diễn chính xác của mảng hai chiều. Vì vậy, sẽ không có bất kỳ sự tăng hiệu suất nào.

  • Làm phẳng mảng hai chiều trong Zokrates. Đây là lựa chọn đơn giản nhất, vì vậy tôi đã chọn nó.

Lưu trữ bản đồ ở đâu

Trong ứng dụng này, gamesInProgress (opens in a new tab) chỉ đơn giản là một biến trong bộ nhớ. Điều này có nghĩa là nếu máy chủ của bạn bị lỗi và cần phải khởi động lại, tất cả thông tin nó lưu trữ sẽ bị mất. Người chơi không chỉ không thể tiếp tục trò chơi của mình, họ thậm chí không thể bắt đầu một trò chơi mới vì thành phần trên chuỗi nghĩ rằng họ vẫn đang trong một trò chơi.

Đây rõ ràng là một thiết kế tồi cho một hệ thống sản xuất, trong đó bạn sẽ lưu trữ thông tin này trong một cơ sở dữ liệu. Lý do duy nhất tôi sử dụng một biến ở đây là vì đây là một hướng dẫn và sự đơn giản là yếu tố chính cần xem xét.

Kết luận: Kỹ thuật này phù hợp trong những điều kiện nào?

Vì vậy, bây giờ bạn đã biết cách viết một trò chơi với một máy chủ lưu trữ trạng thái bí mật không thuộc về trên chuỗi. Nhưng trong những trường hợp nào bạn nên làm điều đó? Có hai yếu tố chính cần xem xét.

  • Trò chơi kéo dài: Như đã đề cập ở trên, trong một trò chơi ngắn, bạn có thể chỉ cần công bố trạng thái khi trò chơi kết thúc và có mọi thứ được xác minh sau đó. Nhưng đó không phải là một lựa chọn khi trò chơi kéo dài hoặc vô thời hạn, và trạng thái cần phải được giữ bí mật.

  • Một số sự tập trung có thể chấp nhận được: Bằng chứng không kiến thức có thể xác minh tính toàn vẹn, rằng một thực thể không giả mạo kết quả. Điều mà chúng không thể làm là đảm bảo rằng thực thể đó sẽ vẫn có sẵn và trả lời các thông điệp. Trong các tình huống mà tính khả dụng cũng cần được phi tập trung, bằng chứng không kiến thức không phải là một giải pháp đủ, và bạn cần tính toán đa bên (opens in a new tab).

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

Ghi nhận

  • Alvaro Alonso đã đọc bản nháp của bài viết này và làm sáng tỏ một số hiểu lầm của tôi về Zokrates.

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: 25 tháng 2, 2026

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