Sử dụng không tri thức cho một trạng thái bí mật
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 mở 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ần vào một trình khám phá 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 từ 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 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à chính xác.
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 máy khách để 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 mà chúng ta sử dụng sẽ 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úng | 1.1.9 |
| TypeScript (opens in a new tab) | Ngôn ngữ lập trình cho cả máy chủ và máy khách | 5.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ối | 2.9.20 |
| MUD (opens in a new tab) | Quản lý dữ liệu trên chuỗi | 2.0.12 |
| React (opens in a new tab) | Giao diện người dùng máy khách | 18.2.0 |
| Vite (opens in a new tab) | Phục vụ mã máy khách | 4.2.1 |
Ví dụ về Minesweeper
Minesweeper (opens in a new tab) là một trò chơi bao gồm một 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 ô vuông 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 quyền 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 ta một cách không phép.
Chạy ví dụ Minesweeper
Để chạy ví dụ Minesweeper:
-
Đảm bảo 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). -
Sao chép (clone) kho lưu trữ.
git clone https://github.com/qbzzt/20240901-secret-state.git -
Cài đặt các gói.
cd 20240901-secret-state/ pnpm install npm install -g mprocsNế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. -
Biên dịch các hợp đồng
cd packages/contracts forge build cd ../.. -
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.
mprocsLư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 mũi tên xuống để cuộn đến tab contracts nhằm 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. Tại đó, bạn chờ 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 tiến trình khác nhau ở bên trái và đầu ra bảng điều khiển cho tiến trình hiện được chọn ở bên phải.Nếu có sự cố với
mprocs, bạn có thể chạy thủ công bốn tiến trình, mỗi tiến trình trong cửa sổ dòng lệnh riêng của nó:-
Anvil
cd packages/contracts anvil --base-fee 0 --block-time 2 -
Hợp đồng
cd packages/contracts pnpm mud dev-contracts --rpc http://127.0.0.1:8545 -
Server
cd packages/server pnpm start -
Client
cd packages/client pnpm run dev
-
-
Bây giờ bạn có thể duyệt đến client (opens in a new tab), nhấp vào New Game và bắt đầu chơi.
Các 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 đơn thể, nó không có khóa và chỉ có một bản ghi duy nhất. Nó được sử dụng để lưu giữ thông tin cấu hình trò chơi:height: Chiều cao của bãi mìnwidth: Chiều rộng của bãi mìnnumberOfBombs: Số lượng bom trong mỗi bãi mìn
-
VerifierAddress: Bảng này cũng là một đơn thể. Nó được sử dụng để lưu giữ một phần của cấu hình, địa chỉ của hợp đồng trình xác minh (verifier). Chúng ta có thể đã đặt thông tin này vào bảngConfiguration, nhưng nó được thiết lập bởi một thành phần khác là máy chủ, vì vậy sẽ dễ dàng hơn khi đặt nó vào một bảng riêng biệt. -
PlayerGame: Khóa là địa chỉ của người chơi. Dữ liệu là:gameId: Giá trị 32-byte là 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 người chơi đã thắng trò chơi hay chưa.lose: một giá trị boolean cho biết 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 chứa ánh xạ ngược, từgameIdsang địa chỉ người chơi. -
Map: Khóa là một tuple gồm ba giá trị:gameId: Giá trị 32-byte là 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 phát hiện có bom. Nếu không, nó là số lượng bom xung quanh vị trí đó cộng thêm một. Chúng ta không thể chỉ sử dụng số lượng bom, vì theo mặc định, tất cả bộ nhớ trong EVM và tất cả các giá trị hàng trong MUD đều là số 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 không có quả bom nào xung quanh".
Ngoài ra, giao tiếp giữa client 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 triển khai bằng cách sử dụng các bảng.
PendingGame: Các yêu cầu bắt đầu trò chơi mới chưa được phục vụ.PendingDig: Các yêu cầu đào ở một vị trí cụ thể trong một trò chơi cụ thể chưa được phục vụ. Đâ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ớ EVM, nó chỉ có thể đọc được ngoài chuỗi bằng cách sử dụ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 client, 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ẽ diễn ra:
-
mprocs(opens in a new tab) chạy bốn thành phần:- Anvil (opens in a new tab), chạy một chuỗi khối cục bộ
- Contracts (opens in a new tab), biên dịch (nếu cần) và triển khai các hợp đồng cho MUD
- Client (opens in a new tab), chạy Vite (opens in a new tab) để phục vụ giao diện người dùng (UI) và mã client cho các trình duyệt web.
- Server (opens in a new tab), thực hiện các hành động của máy chủ
-
Gói
contractstriển khai các hợp đồng MUD và sau đó chạy tập lệnhPostDeploy.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). -
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). Cùng với 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ủ.
-
Máy chủ đăng ký một hàm để thực thi khi bảng
Configurationthay đổi (opens in a new tab). Hàm này (opens in a new tab) được gọi sau khiPostDeploy.s.solthực thi và sửa đổi bảng. -
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 tri 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 tri thức phải có chiều rộng và chiều cao của bãi mìn dưới dạng hằng số. -
Sau khi phần không tri 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 tri thức lên chuỗi khối (opens in a new tab) và thiết lập địa chỉ của bên được xác minh trong MUD.
-
Cuối cùng, chúng ta đăng ký nhận các bản cập nhật để có thể thấy khi nào 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.
-
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, client 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). -
newGame(opens in a new tab) là một lệnh gọiSystem. Trong MUD, tất cả các lệnh gọi đều được định tuyến qua hợp đồngWorld, và trong hầu hết các trường hợp, bạn gọi<namespace>__<function name>. Trong trường hợp này, lệnh gọi là tớiapp__newGame, sau đó MUD định tuyến tớinewGametrongGameSystem(opens in a new tab). -
Hàm trên chuỗi kiểm tra xem người chơi có trò chơi nào đang diễn ra 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). -
Máy chủ phát hiện sự thay đổi trong
PendingGamevà chạy hàm đã đăng ký (opens in a new tab). Hàm này gọinewGame(opens in a new tab), sau đó hàm này lại gọicreateGame(opens in a new tab). -
Điều đầu tiên
createGamelàm là tạo một bản đồ ngẫu nhiên với số lượng mìn thích hợp (opens in a new tab). Sau đó, nó gọimakeMapBorders(opens in a new tab) để tạo một bản đồ có viền trống, điều này là cần thiết cho Zokrates. Cuối cùng,createGamegọicalculateMapHash, để lấy mã băm của bản đồ, được sử dụng làm ID trò chơi. -
Hàm
newGamethêm trò chơi mới vàogamesInProgress. -
Điều cuối cùng máy chủ làm là gọi
app__newGameResponse(opens in a new tab), nằm trên chuỗi. Hàm này nằm trong mộtSystemkhác,ServerSystem(opens in a new tab), để kích hoạt 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 hàm 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ủ. -
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.
- Tạo trò chơi trong
-
Máy chủ nhận dạng sự thay đổi trong
PendingGame, nhưng không làm gì cả vìwantsGame(opens in a new tab) là false. -
Trên client,
gameRecord(opens in a new tab) được đặt thành mục nhậpPlayerGamecho địa chỉ của người chơi. KhiPlayerGamethay đổi,gameRecordcũng thay đổi theo. -
Nếu có một giá trị trong
gameRecord, và trò chơi chưa phân thắng bại, client sẽ hiển thị bản đồ (opens in a new tab).
Đào
-
Người chơi nhấp vào nút của ô bản đồ (opens in a new tab), thao tác này sẽ gọi hàm
dig(opens in a new tab). Hàm này gọidigtrên chuỗi (opens in a new tab). -
Thành phần trên chuỗi thực hiện một số kiểm tra tính hợp lệ (sanity checks) (opens in a new tab), và nếu thành công sẽ thêm yêu cầu đào vào
PendingDig(opens in a new tab). -
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ó gọi mã không tri thức (opens in a new tab) (được giải thích bên dưới) để tạo ra cả kết quả và bằng chứng cho thấy nó hợp lệ. -
Máy chủ (opens in a new tab) gọi
digResponse(opens in a new tab) trên chuỗi. -
digResponsethự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 hợp lệ, nó gọiprocessDigResult(opens in a new tab) để thực sự xử lý kết quả. -
processDigResultkiểm tra xem trò chơi đã thua (opens in a new tab) hay thắng (opens in a new tab), và cập nhậtMap, bản đồ trên chuỗi (opens in a new tab). -
Client 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ể, sẽ 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 được giải thích ở trên, chúng ta đã bỏ qua các phần không tri 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.
Quá trình 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à do đó mã được tối ưu hóa cho sự đơn giản, không phải cho hiệu suất. Vì vậy, 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 mã băm của một bản đồ (hash) và một chương trình để 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 mã băm của một bản đồ. Chúng ta sẽ đi qua mã này từng dòng một.
import "hashes/poseidon/poseidon.zok" as poseidon;
import "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 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 (field element) trong Zokrates thường dài dưới 256 bit, nhưng không ít hơn nhiều. Để đơn giản hóa mã, chúng ta 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 ta 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.
def hashMap(bool[${width+2}][${height+2}] map) -> field {
Dòng này bắt đầu một định nghĩa hàm. hashMap nhận một tham số duy nhất gọi 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} và ${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 (template strings) (opens in a new tab). Mã giữa ${ và } được đánh giá bởi JavaScript, và theo 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í xung quanh nó mà không có bất kỳ quả bom nào, đó là lý do chúng ta cần cộng thêm hai vào chiều rộng và chiều cao.
Giá trị trả về là một field chứa mã băm.
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 các 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).
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.
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ó cá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ở cuộn" (unroll) 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.
for u32 y in 0..${height+2} {
map1d[counter] = map[x][y];
counter = counter+1;
}
}
Đối với mỗi vị trí trong bản đồ, hãy đặt giá trị đó vào mảng map1d và tăng bộ đếm.
field[4] hashMe = [
pack128(map1d[0..128]),
pack128(map1d[128..256]),
pack128(map1d[256..384]),
pack128(map1d[384..512])
];
Sử dụng 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.
return poseidon(hashMe);
}
Sử dụng poseidon để chuyển đổi mảng này thành một mã băm.
Chương trình băm
Máy chủ cần gọi trực tiếp hashMap để tạo các đị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 ta tạo một chương trình với một main gọi hàm băm.
${hashFragment}
def main(bool[${width+2}][${height+2}] map) -> field {
return hashMap(map);
}
Chương trình đào
Đây là trung tâm của phần không tri thức của ứng dụng, nơi chúng ta tạo ra các bằng chứng được sử dụng để xác minh kết quả đào.
${hashFragment}
// Số lượng mìn ở vị trí (x,y)
def map2mineCount(bool[${width+2}][${height+2}] map, u32 x, u32 y) -> u8 {
return if map[x+1][y+1] { 1 } else { 0 };
}
Tại sao lại có viền bản đồ
Các bằng chứng không kiến thức sử dụng mạch số học (arithmetic circuits) (opens in a new tab), vốn không có một cấu trúc tương đương dễ dàng cho câu lệnh if. Thay vào đó, chúng sử dụng cấu trúc 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 toán if a { b } else { c } dưới dạng ab+(1-a)c.
Vì lý do 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ó đoạn mã này:
bool[5] arr = [false; 5];
u32 index=10;
return 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 này 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í xung 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í ở một hàng phía trên và phía dưới, bên trái và bên phải của vị trí mà 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.
def 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 điểm đó 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 đó trình 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 vẫn 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 cơ hội khác cho việc lạm dụng. Trình chứng minh có thể sử dụng 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ể tại chính vị trí đó. Để ngăn chặn sự lạm dụng này, chúng ta làm cho bằng chứng không kiến thức bao gồm mã băm của bản đồ, đây chính là định danh của trò chơi.
return (hashMap(map),
Giá trị trả về ở đây là một tuple bao gồm mảng mã băm bản đồ cũng như kết quả đào.
if map2mineCount(map, x, y) > 0 { 0xFF } else {
Chúng ta sử dụng 255 như một giá trị đặc biệt trong trường hợp bản thân vị trí đó có một quả bom.
map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
}
);
}
Nếu người chơi chưa chạm phải 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).
import { 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 sẽ phân giải thành tất cả các định nghĩa Zokrates.
export const zkFunctions = async (width: number, height: number) : Promise<any> => {
Tương tự như bản thân Zokrates, chúng ta cũng chỉ xuất một hàm duy nhất, hàm này cũng bất đồng bộ (opens in a new tab). Khi nó trả về kết quả, nó cung cấp một số hàm như chúng ta sẽ thấy bên dưới.
const zokrates = await zokratesInitialize()
Khởi tạo Zokrates, lấy mọi thứ chúng ta cần từ thư viện.
const hashFragment = `
import "utils/pack/bool/pack128.zok" as pack128;
import "hashes/poseidon/poseidon.zok" as poseidon;
.
.
.
}
`
const hashProgram = `
${hashFragment}
.
.
.
`
const digProgram = `
${hashFragment}
.
.
.
`
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.
const digCompiled = zokrates.compile(digProgram)
const hashCompiled = zokrates.compile(hashProgram)
Tại đây chúng ta biên dịch các chương trình đó.
// Tạo các khóa để xác minh không tri thức.
// Trên hệ thống sản xuất, bạn sẽ muốn sử dụng một nghi thức thiết lập.
// (https://zokrates.github.io/toolbox/trusted_setup.html#initializing-a-phase-2-ceremony).
const keySetupResults = zokrates.setup(digCompiled.program, "")
const verifierKey = keySetupResults.vk
const 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 thức thiết lập (setup ceremony) (opens in a new tab) phức tạp hơn, nhưng điều này là đủ tốt cho một bản trình diễn. Việc người dùng có thể biết khóa của trình chứng minh không phải là vấn đề - họ vẫn không thể sử dụng nó để chứng minh mọi thứ trừ khi chúng là 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
const calculateMapHash = function (hashMe: boolean[][]): string {
return (
"0x" +
BigInt(zokrates.computeWitness(hashCompiled, [hashMe]).output.slice(1, -1))
.toString(16)
.padStart(64, "0")
)
}
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 về 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 điểm đánh dấu cho các số thập lục phân.
// Đào và trả về một bằng chứng không kiến thức của kết quả
// (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 (x và y) và kết quả (mã băm của bản đồ và số lượng bom).
const zkDig = function(map: boolean[][], x: number, y: number) : any {
if (x<0 || x>=width || y<0 || y>=height)
throw new Error("Trying to dig outside the map")
Việc 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.
const runResults = zokrates.computeWitness(digCompiled, [map, `${x}`, `${y}`])
Thực thi chương trình đào.
const proof = zokrates.generateProof(
digCompiled.program,
runResults.witness,
proverKey)
return proof
}
Sử dụng generateProof (opens in a new tab) và trả về bằng chứng.
const solidityVerifier = `
// Map size: ${width} x ${height}
\n${zokrates.exportSolidityVerifier(verifierKey)}
`
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 bởi digCompiled.program.
return {
zkDig,
calculateMapHash,
solidityVerifier,
}
}
Cuối cùng, trả về mọi thứ mà mã khác có thể cần.
Các bài kiểm tra bảo mật
Các bài kiểm tra bảo mật rất quan trọng vì một lỗi chức năng cuối cùng sẽ tự bộc lộ. Nhưng nếu ứng dụng không an toàn, điều đó có khả năng bị ẩn giấu trong một thời gian dài trước khi bị phát hiện bởi một kẻ gian lận nào đó và chiếm đoạt các tài nguyên thuộc về người khác.
Quyền hạn
Có một thực thể có đặc quyền trong trò chơi này, đó là máy chủ. Đây 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 các lệnh gọi đến các hàm có cấp phép chỉ được cho phép dưới dạng 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).
-
Trên máy tính chạy
anvil(chuỗi khối), hãy thiết lập các biến môi trường này.WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d -
Sử dụng
castđể thử đặt địa chỉ trình xác minh thành một địa chỉ không được ủy quyền.cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEYKhông chỉ
castbáo cáo lỗi, mà bạn cò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. Hãy xem rằng địa chỉ không phải là số không. -
Đặt địa chỉ trình xác minh thành địa chỉ của máy chủ.
cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $AUTHORIZED_KEYĐịa chỉ trong app__VerifiedAddress bây giờ sẽ là số không.
Tất cả các hàm MUD trong cùng một System đều đi qua cùng một cơ chế kiểm soát truy cập, vì vậy tôi coi bài kiểm tra 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).
Lạm dụng không tri thức
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 nhiều kiểm tra khác nhau trên mã không tri thức để xác minh rằng nếu nó không được thực hiện chính xác thì 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 chỉ khởi động lại tiến trình máy chủ là không đủ, vì nó đặt ứng dụng vào một trạng thái bất khả thi (người chơi đang có một trò chơi đang diễn ra, nhưng trò chơi đó không còn khả dụng đối với máy chủ nữa).
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 đi vào bên trong zkDig và sửa đổi dòng 91 (opens in a new tab):
proof.inputs[3] = "0x" + "1".padStart(64, "0")
Điều này có nghĩa là chúng ta sẽ luôn khẳng định 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:
cause: {
code: 3,
message: 'execution reverted: revert: Zero knowledge verification fail',
data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
e206661696c'
},
Vì vậy, kiểu gian lận này sẽ thất bại.
Bằng chứng sai
Điều gì xảy ra nếu chúng ta cung cấp thông tin chính xác, nhưng lại có dữ liệu bằng chứng sai? Bây giờ, hãy thay thế dòng 91 bằng:
proof.proof = {
a: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
b: [
["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
],
c: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
}
Nó vẫn thất bại, nhưng bây giờ nó thất bại mà không có lý do vì điều đó xảy ra trong quá trình gọi trình xác minh.
Làm thế nào người dùng có thể xác minh mã zero trust?
Các hợp đồng thông minh tương đối dễ xác minh. Thông thường, nhà phát triển công bố mã nguồn lên một trình khám phá khối, và trình khám phá khối sẽ 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 tri 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 những gì đang được chứng minh.
function verifyingKey() pure internal returns (VerifyingKey memory vk) {
vk.alpha = Pairing.G1Point(uint256(0x0f43f4fe7b5c2326fed4ac6ed2f4003ab9ab4ea6f667c2bdd77afb068617ee16), uint256(0x25a77832283f9726935219b5f4678842cda465631e72dbb24708a97ba5d0ce6f));
vk.beta = Pairing.G2Point([uint256(0x2cebd0fbd21aca01910581537b21ae4fed46bc0e524c055059aa164ba0a6b62b), uint256(0x18fd4a7bc386cf03a95af7163d5359165acc4e7961cb46519e6d9ee4a1e2b7e9)], [uint256(0x11449dee0199ef6d8eebfe43b548e875c69e7ce37705ee9a00c81fe52f11a009), uint256(0x066d0c83b32800d3f335bb9e8ed5e2924cf00e77e6ec28178592eac9898e1a00)]);
Giải pháp, ít nhất là cho đến khi các trình khám phá khối thêm tính năng 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 phải cung cấp sẵn các chương trình Zokrates, và ít nhất một số người dùng phải tự biên dịch chúng với khóa xác minh phù hợp.
Để làm như vậy:
-
Tạo một tệp,
dig.zok, với chương trình Zokrates. Mã bên dưới giả định rằng bạn đã giữ nguyên kích thước bản đồ ban đầu là 10x5.import "utils/pack/bool/pack128.zok" as pack128; import "hashes/poseidon/poseidon.zok" as poseidon; def hashMap(bool[12][7] map) -> field { bool[512] mut map1d = [false; 512]; u32 mut counter = 0; for u32 x in 0..12 { for u32 y in 0..7 { map1d[counter] = map[x][y]; counter = counter+1; } } field[4] hashMe = [ pack128(map1d[0..128]), pack128(map1d[128..256]), pack128(map1d[256..384]), pack128(map1d[384..512]) ]; return poseidon(hashMe); } // Số lượng mìn ở vị trí (x,y) def map2mineCount(bool[12][7] map, u32 x, u32 y) -> u8 { return if map[x+1][y+1] { 1 } else { 0 }; } def main(private bool[12][7] map, u32 x, u32 y) -> (field, u8) { return (hashMap(map) , if map2mineCount(map, x, y) > 0 { 0xFF } else { map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) + map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) + map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1) } ); } -
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 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).
zokrates compile --input dig.zok zokrates setup -e "" -
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ủ có thêm một bình luận, nhưng điều đó không quan trọng).
zokrates export-verifier diff 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 nào đủ phức tạp, luôn có những mục tiêu thiết kế cạnh tranh nhau đòi hỏi sự đánh đổi. Hãy cùng xem xét một số sự đánh đổi và lý do tại sao giải pháp hiện tại lại được ưu tiên hơn các lựa chọn khác.
Tại sao lại là không tri thức
Đối với trò chơi dò mìn, bạn không thực sự cần công nghệ không tri thức. Máy chủ luôn có thể giữ bản đồ, và sau đó chỉ cần tiết lộ toàn bộ 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 mã băm của bản đồ, xác minh xem nó có khớp hay không, và nếu không khớp thì sẽ phạt máy chủ hoặc hủy bỏ hoàn toàn trò chơi.
Tôi đã không sử dụng giải pháp đơn giản hơn này vì nó chỉ hoạt động đối với các trò chơi ngắn có 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 kéo dài vô tậ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ó.
Vì là một bài hướng dẫn, bài viết này cần một trò chơi ngắn gọn và dễ hiểu, nhưng kỹ thuật này hữu ích nhất đối với 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 tri thức duy nhất hiện có, nhưng nó tương tự như một ngôn ngữ lập trình mệnh lệnh (opens in a new tab) thông thường 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ể muốn sử dụng Circum (opens in a new tab) hoặc Cairo (opens in a new tab).
Khi nào nên 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 bài 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 mình có tệp chứa các chương trình Zokrates đã biên dịch ở kích thước bãi mìn này hay không, và nếu có thì sử dụng tệp đó. Điều tương tự cũng áp dụng cho việc triển khai một hợp đồng trình xác minh trên chuỗi.
Tạo khóa trình xác minh và trình chứng minh
Việc tạo khóa (opens in a new tab) là một phép tính thuần túy khác không cần 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 hóa.
Ngoài ra, chúng ta có thể sử dụng một buổi lễ thiết lập (opens in a new tab). Ưu điểm của buổi lễ thiết lập là bạn cần entropy hoặc một số kết quả trung gian từ mỗi người tham gia để gian lận bằng chứng không kiến thức. Nếu có ít nhất một người tham gia buổi 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 đã bị xóa khỏi mọi nơi. Nếu các bằng chứng không kiến thức cực kỳ quan trọng, bạn sẽ muốn tham gia vào buổi lễ thiết lập.
Ở đây chúng ta dựa vào perpetual powers of tau (opens in a new tab), vốn 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 ta cũng không thêm entropy vào trong quá trình tạo khóa, điều này giúp người dùng dễ dàng xác minh cấu hình không tri thức 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 máy khách (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 trình 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 máy khách, bạn sẽ phải xác minh mã bạn nhận được mỗi khi tải xuống máy khách.
Ngoài ra, mặc dù trò chơi này là chơi đơn, nhưng rất nhiều trò chơi chuỗi khối là nhiều người chơi. Việc 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 điều đó trong máy khách sẽ yêu cầu mỗi máy khách phải xác minh một cách độc lập.
Làm phẳng bản đồ trong TypeScript hay Zokrates?
Nhìn chung, khi quá trình xử lý có thể được thực hiện bằng TypeScript hoặc Zokrates, tốt hơn là nên thực hiện bằng TypeScript, vì nó nhanh hơn rất nhiều và không yêu cầu các bằng chứng không kiến thức. Đây là lý do, ví dụ, tại sao chúng ta không cung cấp cho Zokrates mã băm và bắt nó xác minh xem mã băm đó có chính xác hay không. Quá trình băm phải được thực hiện bên trong Zokrates, nhưng việc khớp giữa mã băm được trả về và mã băm trên chuỗi có thể diễn 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à theo ý kiến của tôi, các lựa chọn khác còn 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 mã (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 mức tăng hiệu suất không đáng giá đối với một bài 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ự gia 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ị sập và cần khởi động lại, tất cả thông tin mà 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 họ, mà 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 có một trò chơi đang diễn ra.
Đây rõ ràng là một thiết kế tồi đối với một hệ thống sản xuất, trong đó bạn sẽ lưu trữ thông tin này trong 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 bài hướng dẫn và sự đơn giản là yếu tố được cân nhắc chính.
Kết luận: Kỹ thuật này phù hợp trong những điều kiện nào?
Vậy là 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 bạn nên làm điều đó trong những trường hợp nào? Có hai cân nhắc chính.
-
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 sau khi trò chơi kết thúc và mọi thứ sẽ được xác minh sau đó. Nhưng đó không phải là một lựa chọn khi trò chơi diễn ra trong một thời gian dài hoặc không xác định, và trạng thái cần được giữ bí mật.
-
Chấp nhận một mức độ tập trung nhất định: 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 làm giả kết quả. Điều chúng không thể làm là đảm bảo rằng thực thể đó sẽ luôn khả dụng và trả lời các thông điệp. Trong những 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 đủ tốt, và bạn cần tính toán đa bên (opens in a new tab).
Xem thêm các bài viết khác của tôi tại đây (opens in a new tab).
Lời cảm ơn
- Alvaro Alonso đã đọc bản nháp của bài viết này và làm rõ một số hiểu lầm của tôi về Zokrates.
Mọi sai sót còn lại đều thuộc trách nhiệm của tôi.
