एक Zero-Knowledge Virtual Machine (ZKVM) एक ऐसी वर्चुअल मशीन है जो एक ZK-proof बना सकती है, जो यह सत्यापित करता है कि उसने मशीन निर्देशों के एक सेट को सही ढंग से निष्पादित (execute) किया है। यह हमें एक प्रोग्राम (opcodes का एक सेट) और एक वर्चुअल मशीन स्पेसिफिकेशन (वर्चुअल मशीन कैसे व्यवहार करती है, यह किन opcodes का उपयोग करती है, आदि) लेने की अनुमति देता है, और यह साबित करता है कि जनरेट किया गया आउटपुट सही है। एक verifier को प्रोग्राम को फिर से चलाने की आवश्यकता नहीं होती है, बल्कि केवल जनरेट किए गए ZK proof की जांच करनी होती है — यह वेरिफिकेशन को संक्षिप्त (succinct) बनाता है। ऐसी संक्षिप्तता ही वह आधार है जो ZK layer 2 ब्लॉकचेन को स्केलेबल बनाती है। यह किसी को संपूर्ण एल्गोरिथम को फिर से चलाए बिना यह जांचने में भी सक्षम बनाता है कि कोई मशीन लर्निंग एल्गोरिथम ठीक वैसे ही चला जैसा कि दावा किया गया था।
नाम के विपरीत, ZKVM शायद ही कभी “zero knowledge” होते हैं, इस अर्थ में कि वे कंप्यूटेशन को निजी (private) रखते हैं। इसके बजाय, वे ZK एल्गोरिदम का उपयोग करके एक संक्षिप्त proof तैयार करते हैं कि प्रोग्राम किसी निश्चित इनपुट पर सही ढंग से निष्पादित हुआ है ताकि एक verifier बहुत कम काम (exponentially less work) के साथ कंप्यूटेशन की दोबारा जांच कर सके। भले ही प्रोग्राम इनपुट को प्रकट करना वैकल्पिक है, आकस्मिक डेटा लीक से बचना और कई पार्टियों को प्राइवेट स्टेट (private state) पर सहमत करना बहुत ही चुनौतीपूर्ण इंजीनियरिंग समस्याएं हैं जिनमें अभी भी अनसुलझी चुनौतियां और स्केलिंग सीमाएं हैं।
एक ZKVM opcode के प्रत्येक चरण की “गणना” (computes) करता है, फिर यह constrain करता है कि opcode सही ढंग से निष्पादित किया गया था। Constraints को इस तरह से डिज़ाइन किया जाना चाहिए कि हम एक मनमाने (arbitrary) लेकिन वैध opcode अनुक्रम के साथ काम कर सकें।
हम एक ZKVM को “state” ट्रांजिशन की एक श्रृंखला के रूप में सोच सकते हैं। “state transition function” पिछली state और निष्पादित किए जाने वाले वर्तमान opcode को लेता है, और एक नई state जनरेट करता है। एक ZKVM “state transition function” और सर्किट constraints को लागू करता है जो इसके व्यवहार को मॉडल करते हैं। ध्यान दें कि “state” में “program counter” जैसी चीजें या VM को ठीक से चलाने के लिए आवश्यक अन्य बुककीपिंग शामिल हो सकती है।
यह ट्यूटोरियल एक अत्यंत सरल ZKVM बनाएगा जो केवल बेसिक अंकगणित (basic arithmetic) का समर्थन करता है लेकिन इसे अन्य opcodes के लिए एक्सटेंड (extensible) किया जा सकता है। यहां प्रस्तुत VM में केवल एक स्टैक (stack) है और कोई मेमोरी या स्टोरेज नहीं है। हम लेख के अंत में ZKVMs के आगे के अध्ययन के लिए सुझाव प्रदान करते हैं।
पूर्व आवश्यकताएं (Prerequisites)
यह ट्यूटोरियल यह मानकर चलता है कि आपको EVM (या Java Virtual Machine जैसे किसी अन्य स्टैक-आधारित आर्किटेक्चर) के काम करने के तरीके का बुनियादी ज्ञान है। हम पिछले अध्याय के स्टैक उदाहरण पर बहुत अधिक निर्माण करते हैं, इसलिए हम मानकर चलते हैं कि आपको उसका ज्ञान है।
एक सरल स्टैक-आधारित ZKVM
हम एक सरल स्टैक-आधारित ZKVM बनाएंगे जिसमें एक विशेष उद्देश्य वाला (special-purpose) सिग्नल होगा जिसमें कंप्यूटेशन का परिणाम होगा। VM को opcodes और संख्याओं की एक श्रृंखला दी जाती है, फिर यह अंतिम परिणाम को एक विशेष सिग्नल पर आउटपुट करता है जिसे हम out कहते हैं।
हमारे ZKVM में केवल निम्नलिखित opcodes हैं:
- PUSH (पहले तर्क (argument) को स्टैक पर पुश करता है)
- ADD (स्टैक से शीर्ष (top) दो आइटम पॉप करता है और उनके योग को पुश करता है)
- MUL (स्टैक से शीर्ष दो आइटम पॉप करता है और उनके गुणनफल को पुश करता है)
- NOP (कोई ऑपरेशन नहीं, कुछ मत करो)
सरलता के लिए, सभी opcodes एक ही आर्गुमेंट लेते हैं, लेकिन केवल PUSH ही उस आर्गुमेंट का उपयोग करता है। बाकी निर्देश आर्गुमेंट को अनदेखा कर देते हैं। हम उन opcodes को आर्गुमेंट इसलिए प्रदान करते हैं जो उनका उपयोग नहीं करते हैं, ताकि हमें opcode के आधार पर सशर्त (conditionally) रूप से यह जांचने की आवश्यकता न हो कि आर्गुमेंट मौजूद है या नहीं।
कोई STOP या RETURN opcode नहीं है (इसके विकल्प को जल्द ही समझाया गया है)। VM एक steps आर्गुमेंट लेता है और steps संख्या के निर्देशों को निष्पादित करने के बाद स्टैक के सबसे नीचे जो भी वैल्यू होती है, उसे वापस (return) करता है।
निम्नलिखित एनिमेशन इस आर्किटेक्चर में दो संख्याओं को एक साथ जोड़ने का एक सरल उदाहरण देता है:
Circom में, लूप वेरिएबल-लेंथ (variable-length) के नहीं हो सकते हैं, उन्हें हमेशा एक निश्चित संख्या में इटरेशन (iterations) के लिए निष्पादित किया जाना चाहिए, क्योंकि अंतर्निहित Rank 1 Constraint System (R1CS) का आकार भी निश्चित होना चाहिए। इसी तरह, प्रोग्राम भी वेरिएबल आकार के नहीं हो सकते हैं। हालांकि, प्रोग्राम रन की परवाह किए बिना उनमें opcodes की संख्या समान होनी चाहिए।
निश्चित संख्या से कम opcodes वाले प्रोग्राम को चलाने के लिए, हम बस इसे NOP के साथ पैड (pad) करते हैं जब तक कि प्रोग्राम अपने अधिकतम आकार का न हो जाए। यह जानने के लिए कि “निष्पादन कब रोकना है (stop executing),” उपयोगकर्ता को ऊपर उल्लिखित steps आर्गुमेंट देना होगा ताकि यह निर्धारित किया जा सके कि स्टैक के निचले भाग की वैल्यू कब वापस की जाएगी।
हमारे आर्किटेक्चर के बारे में कुछ मुख्य बातें:
- VM स्टैक-आधारित है, जैसे EVM, Java Virtual Machine, या (जो लोग जानते हैं उनके लिए) एक Reverse Polish Notation calculator।
- कोई जंप (jump) निर्देश नहीं हैं, इसलिए प्रोग्राम काउंटर केवल बढ़ता (increments) है।
- सभी opcodes एक ही आर्गुमेंट लेते हैं, लेकिन ADD, MUL, और NOP उन्हें पास किए गए आर्गुमेंट को अनदेखा कर देते हैं। यह हमें हर बार प्रोग्राम काउंटर को समान मात्रा में बढ़ाने की अनुमति देता है — हमें PUSH के लिए प्रोग्राम काउंटर को 2 से और ADD के लिए 1 से अपडेट करने की आवश्यकता नहीं है। हम हमेशा काउंटर को 2 से आगे बढ़ाते हैं।
- PUSH के आर्गुमेंट को पढ़ने के लिए, हम बस प्रोग्राम काउंटर से एक इंडेक्स आगे “देखते” (look ahead) हैं।
- जोड़ और गुणा मॉड्यूलर अंकगणित (modular arithmetic) का उपयोग करके किए जाते हैं (जो Circom में डिफ़ॉल्ट है)। हम अपने “word size” के रूप में Circom के डिफ़ॉल्ट फील्ड के ऑर्डर का उपयोग करते हैं — हम ऐसे VM का अनुकरण (emulate) करने का प्रयास नहीं करते हैं जिनमें पारंपरिक वर्ड साइज जैसे 64 बिट्स या 256 बिट्स होते हैं। एक निश्चित आकार के बिट्स के साथ कंप्यूटेशन का अनुकरण करना अगले अध्याय का विषय है।
हमारे स्टैक कोड को अपडेट करना
ऊपर बताए गए स्पेसिफिकेशन को पूरा करने वाला ZKVM बनाने के लिए हम पिछले अध्याय के अपने स्टैक कोड में कुछ प्रमुख बदलाव कर सकते हैं।
- हमने POP opcode को हटा दिया है क्योंकि अब इसकी आवश्यकता नहीं है।
- हमने ADD और MUL opcode जोड़ा है।
याद करें कि पिछले स्टैक को कॉपी करने के पूर्व नियम थे:
- A. यदि
sp1 या उससे अधिक है, और कॉलमj,spसे 1 इंडेक्स नीचे है, और वर्तमान निर्देश PUSH या NOP है, तो हमें कॉलमjको कॉपी करना चाहिए। - B. यदि
sp2 या उससे अधिक है, और कॉलमj,spसे 2 इंडेक्स नीचे है, और वर्तमान निर्देश POP है, तो हमें कॉलमjको कॉपी करना चाहिए।
नियम A अपरिवर्तित रहता है, लेकिन B को निम्नलिखित में अपडेट करने की आवश्यकता है:
- B. यदि
sp2 या उससे अधिक है, और कॉलमj,spसे 3 इंडेक्स नीचे है, और वर्तमान निर्देश ADD या MUL है, तो हमें कॉलमjको कॉपी करना चाहिए।
इस परिवर्तन का कारण यह है कि पिछले POP निर्देश ने स्टैक पर ऊपर से दूसरे (second-to-top) आइटम को नहीं बदला था, इसने केवल शीर्ष (top) आइटम को हटा दिया था। हालांकि, ADD प्रभावी रूप से स्टैक को दो बार पॉप करता है और योग (sum) को पुश करता है। इसी तरह, MUL स्टैक को दो बार पॉप करता है और गुणनफल (product) को पुश करता है।
पिछले स्टैक इम्प्लीमेंटेशन ने केवल स्टैक पॉइंटर पर नई वैल्यू लिखी थीं। हालांकि, नया इम्प्लीमेंटेशन स्टैक पॉइंटर के नीचे दो इंडेक्स पर योग या गुणनफल लिख सकता है। उदाहरण के लिए, नीचे दिए गए स्टैक में 12 जुड़ने के बाद 15 हो जाएगा, और वह स्थान स्टैक पॉइंटर से दो इंडेक्स नीचे है:
जोड़ने से पहले:
[12 , 3, sp] (sp = 3)
जोड़ने के बाद:
[15, sp] (sp = 2)
यहां, हमारे पास स्टैक के निचले भाग में 12 है और sp स्टैक के ऊपर खाली स्थान को इंगित कर रहा है।
इसलिए, हमें यह इंगित करने के लिए एक सिग्नल की आवश्यकता है कि कोई विशेष कॉलम स्टैक पॉइंटर से दो तत्व (elements) नीचे है।
नीचे दिया गया कोड काफी हद तक पिछले अध्याय के स्टैक से लिया गया है, लेकिन यह इस अध्याय में वर्णित अपडेट को लागू करता है। विशेष रूप से:
- हमने NOP, PUSH, और POP को NOP, PUSH, ADD, और MUL से बदल दिया है। ADD और MUL स्टैक पॉइंटर को एक से कम (decrement) कर देते हैं, NOP स्टैक पॉइंटर को वही रखता है, और PUSH स्टैक पॉइंटर को एक से बढ़ा देता है और अपने आर्गुमेंट को स्टैक के शीर्ष पर कॉपी कर देता है।
pragma circom 2.1.6;
include "circomlib/comparators.circom";
include "circomlib/gates.circom";
template AND3() {
signal input in[3];
signal output out;
signal temp;
temp <== in[0] * in[1];
out <== temp * in[2];
}
// i is the column number
// bits is how many bits we need
// for the LessEqThan component
template ShouldCopy(i, bits) {
signal input sp;
signal input is_push;
signal input is_nop;
signal input is_add;
signal input is_mul;
// out = 1 if should copy
signal output out;
// sanity checks
is_add + is_mul + is_push + is_nop === 1;
is_nop * (1 - is_nop) === 0;
is_push * (1 - is_push) === 0;
is_add * (1 - is_add) === 0;
is_mul * (1 - is_mul) === 0;
// it's cheaper to compute ≠ 0 than > 0 to avoid
// converting the number to binary
signal spEqZero;
signal spGteOne;
spEqZero <== IsZero()(sp);
spGteOne <== 1 - spEqZero;
// it's cheaper to compute ≠ 0 and ≠ 1 than ≥ 2
signal spEqOne;
signal spGteTwo;
spEqOne <== IsEqual()([sp, 1]);
spGteTwo <== 1 - spEqOne * spEqZero;
// the current column is 1 or more
// below the stack pointer
signal oneBelowSp <== LessEqThan(bits)([i, sp - 1]);
// the current column is 3 or more
// below the stack pointer
signal threeBelowSP <== LessEqThan(bits)([i, sp - 3]);
// condition A
component a3A = AND3();
a3A.in[0] <== spGteOne;
a3A.in[1] <== oneBelowSp;
a3A.in[2] <== is_push + is_nop;
// condition B
component a3B = AND3();
a3B.in[0] <== spGteTwo;
a3B.in[1] <== threeBelowSP;
a3B.in[2] <== is_add + is_mul;
component or = OR();
or.a <== a3A.out;
or.b <== a3B.out;
out <== or.out;
}
template CopyStack(m) {
var nBits = 4;
signal output out[m];
signal input sp;
signal input is_add;
signal input is_mul;
signal input is_push;
signal input is_nop;
component ShouldCopys[m];
signal copy[m];
// loop over the columns
for (var i = 0; i < m; i++) {
ShouldCopys[i] = ShouldCopy(i, nBits);
ShouldCopys[i].sp <== sp;
ShouldCopys[i].is_add <== is_add;
ShouldCopys[i].is_mul <== is_mul;
ShouldCopys[i].is_push <== is_push;
ShouldCopys[i].is_nop <== is_nop;
out[i] <== ShouldCopys[i].out;
}
}
// n is how many instructions we can handle
// since all the instructions might be push,
// our stack needs capacity of up to n
template ZKVM(n) {
var NOP = 0;
var PUSH = 1;
var ADD = 2;
var MUL = 3;
signal input instr[2 * n];
// we add one extra row for sp because
// our algorithm always writes to the
// next row and we don't want to conditionally
// check for an array-out-of-bounds
signal output sp[n + 1];
signal output stack[n][n];
var IS_NOP = 0;
var IS_PUSH = 1;
var IS_ADD = 2;
var IS_MUL = 3;
var ARG = 4;
signal metaTable[n][5];
// first instruction must be PUSH or NOP
(instr[0] - PUSH) * (instr[0] - NOP) === 0;
signal first_op_is_push;
first_op_is_push <== IsEqual()([instr[0], PUSH]);
// if the first op is NOP, we are forcing the first
// value to be zero, but this is where the stack
// pointer is, so it doesn't matter
stack[0][0] <== first_op_is_push * instr[1];
// initialize the rest of the first stack to be zero
for (var i = 1; i < n; i++) {
stack[0][i] <== 0;
}
// we fill out the 0th elements to avoid
// uninitialzed signals
sp[0] <== 0;
sp[1] <== first_op_is_push;
metaTable[0][IS_PUSH] <== first_op_is_push;
metaTable[0][IS_NOP] <== 1 - first_op_is_push;
metaTable[0][IS_ADD] <== 0;
metaTable[0][IS_MUL] <== 0;
metaTable[0][ARG] <== instr[1];
// spBranch is what we add to the previous stack pointer
// based on the opcode. Could be 1, 0, or -1 depending on the
// opcode. Since the first opcode cannot be POP, -1 is not
// an option here.
var SAME = 0;
var INC = 1;
var DEC = 2;
signal spBranch[n][3];
spBranch[0][INC] <== first_op_is_push * 1;
spBranch[0][SAME] <== (1 - first_op_is_push) * 0;
spBranch[0][DEC] <== 0;
// populate the metaTable and the stack pointer
component EqPush[n];
component EqNop[n];
component EqAdd[n];
component EqMul[n];
component eqSP[n][n];
signal eqSPAndIsPush[n][n];
for (var i = 0; i < n; i++) {
eqSPAndIsPush[0][i] <== 0;
}
// signals and components for copying
component CopyStack[n];
signal previousCellIfShouldCopy[n][n];
for (var i = 0; i < n; i++) {
previousCellIfShouldCopy[0][i] <== 0;
}
component eqSPMinus2[n][n];
signal eqSPMinus2AndIsAdd[n][n];
signal eqSPMinus2AndIsMul[n][n];
for (var i = 0; i < n; i++) {
eqSPMinus2AndIsAdd[0][i] <== 0;
eqSPMinus2AndIsMul[0][i] <== 0;
}
// (the current column = sp - 2 and is_add) * sum
signal eqSPMinus2AndIsAddWithValue[n][n];
signal eqSPMinus2AndIsMulWithValue[n][n];
signal sum_result[n][n];
signal mul_result[n][n];
for (var i = 0; i < n; i++) {
eqSPMinus2AndIsAddWithValue[0][i] <== 0;
eqSPMinus2AndIsMulWithValue[0][i] <== 0;
sum_result[0][i] <== 0;
mul_result[0][i] <== 0;
}
for (var i = 1; i < n; i++) {
// check which opcode we are executing
EqPush[i] = IsEqual();
EqPush[i].in[0] <== instr[2 * i];
EqPush[i].in[1] <== PUSH;
metaTable[i][IS_PUSH] <== EqPush[i].out;
EqNop[i] = IsEqual();
EqNop[i].in[0] <== instr[2 * i];
EqNop[i].in[1] <== NOP;
metaTable[i][IS_NOP] <== EqNop[i].out;
EqAdd[i] = IsEqual();
EqAdd[i].in[0] <== instr[2 * i];
EqAdd[i].in[1] <== ADD;
metaTable[i][IS_ADD] <== EqAdd[i].out;
EqMul[i] = IsEqual();
EqMul[i].in[0] <== instr[2 * i];
EqMul[i].in[1] <== MUL;
metaTable[i][IS_MUL] <== EqMul[i].out;
// carry out the sums and muls
for (var j = 0; j < n - 1; j++) {
sum_result[i][j] <== stack[i - 1][j] + stack[i - 1][j + 1];
mul_result[i][j] <== stack[i - 1][j] * stack[i - 1][j + 1];
}
// these values cannot be used in practice because
// the stack doesn't go that high.
// However, we still need to initialize
// them because every column checks
// if it is sp - 1, even the last 2
for (var j = n - 1; j < n; j++) {
sum_result[i][j] <== 0;
mul_result[i][j] <== 0;
}
// get the instruction argument
metaTable[i][ARG] <== instr[2 * i + 1];
// if it is a push, write to the stack
// if it is a copy, write to the stack
CopyStack[i] = CopyStack(n);
CopyStack[i].sp <== sp[i];
CopyStack[i].is_push <== metaTable[i][IS_PUSH];
CopyStack[i].is_nop <== metaTable[i][IS_NOP];
CopyStack[i].is_add <== metaTable[i][IS_ADD];
CopyStack[i].is_mul <== metaTable[i][IS_MUL];
for (var j = 0; j < n; j++) {
previousCellIfShouldCopy[i][j] <== CopyStack[i].out[j] * stack[i - 1][j];
eqSP[i][j] = IsEqual();
eqSP[i][j].in[0] <== j;
eqSP[i][j].in[1] <== sp[i];
eqSPAndIsPush[i][j] <== eqSP[i][j].out * metaTable[i][IS_PUSH];
// check if the column is two less
// than the stack pointer
// if so, we prepare to write the sum or
// product here
// if the current instruction is add or mul
eqSPMinus2[i][j] = IsEqual();
eqSPMinus2[i][j].in[0] <== j;
eqSPMinus2[i][j].in[1] <== sp[i] - 2; // underflow doesn't matter
eqSPMinus2AndIsAdd[i][j] <== eqSPMinus2[i][j].out * metaTable[i][IS_ADD];
eqSPMinus2AndIsMul[i][j] <== eqSPMinus2[i][j].out * metaTable[i][IS_MUL];
eqSPMinus2AndIsAddWithValue[i][j] <== eqSPMinus2AndIsAdd[i][j] * sum_result[i][j];
eqSPMinus2AndIsMulWithValue[i][j] <== eqSPMinus2AndIsMul[i][j] * mul_result[i][j];
// we will either
// - PUSH
// - COPY or implicilty assign 0
// - ADD
// - MUL
stack[i][j] <== eqSPAndIsPush[i][j] * metaTable[i][ARG] + previousCellIfShouldCopy[i][j] + eqSPMinus2AndIsAddWithValue[i][j] + eqSPMinus2AndIsMulWithValue[i][j];
}
// write to the next row's stack pointer
spBranch[i][INC] <== metaTable[i][IS_PUSH] * (sp[i] + 1);
spBranch[i][SAME] <== metaTable[i][IS_NOP] * (sp[i]);
spBranch[i][DEC] <== (metaTable[i][IS_ADD] + metaTable[i][IS_MUL]) * (sp[i] - 1);
sp[i + 1] <== spBranch[i][INC] + spBranch[i][SAME] + spBranch[i][DEC];
}
}
component main = ZKVM(5);
/* INPUT = {
"instr": [1,3,1,6,1,2,3,0,3,0]
} */
यदि हम केवल एक का उपयोग करते हैं तो हर opcode के लिए constraint होना क्या अक्षम (inefficient) नहीं है?
हमारे ZKVM में, हम स्टैक में प्रत्येक तत्व के लिए एक जोड़ और गुणा करते हैं, भले ही हम वास्तव में उनमें से केवल एक का उपयोग करते हैं। यह जोड़ या गुणा जैसे बहुत हल्के ऑपरेशन के लिए परिणामी (consequential) नहीं है। हालांकि, अगर हमारे पास हैशिंग (hashing) जैसे भारी ऑपरेशन के लिए एक opcode होता, तो यह काफी अधिक constraints उत्पन्न करता; हमें स्टैक में प्रत्येक आइटम के लिए हैशिंग के लिए एक सर्किट को पॉप्युलेट करना होगा, भले ही स्टैक के केवल शीर्ष को हैश करने की आवश्यकता हो। इस सब के परिणामस्वरूप अनावश्यक कंप्यूटेशन और उच्च कम्प्यूटेशनल ओवरहेड होगा।
हम यह निर्धारित करने के लिए कि स्टैक पर कौन से आइटम opcode के लिए इनपुट होंगे, एक Quin selector (या दो) का उपयोग करके दक्षता में सुधार कर सकते हैं, लेकिन इसका अभी भी यही अर्थ है कि स्टैक के प्रत्येक इटरेशन को हैश के लिए constraints की आवश्यकता होती है, भले ही वह उनका उपयोग न करे।
हम पाठकों के लिए एक अभ्यास के रूप में पूरे स्टैक के बजाय केवल स्टैक पर शीर्ष दो आइटमों को जोड़ने या गुणा करने के लिए दो Quin selectors को लागू करने का कार्य छोड़ते हैं।
अप्रयुक्त (unused) constraints को अनावश्यक रूप से दोहराने की यह अक्षमता वेनिला R1CS की एक गंभीर कमी है, जो constraints के सशर्त उपयोग की अनुमति नहीं देता है।
दक्षता में सुधार के समाधान
दक्षता में नाटकीय रूप से सुधार के लिए दो आधुनिक दृष्टिकोण हैं: लुकअप टेबल (lookup tables) और रिकर्सिव प्रूफ्स (recursive proofs) पर आधारित constraints।
- एक लुकअप टेबल एक arithmetization स्कीम है जहां केवल वे constraints जो वास्तव में उपयोग किए जाते हैं, वे एक टेबल का हिस्सा होते हैं, और फिर ZK proof यह साबित करता है कि उसने प्रत्येक निर्देश पर टेबल से सही प्रविष्टि (entry) का उपयोग किया।
- एक रिकर्सिव प्रूफ प्रत्येक निर्देश के लिए एक अलग ZK proof बनाता है और फिर उस प्रूफ को एक अन्य ZK proof के साथ जोड़ता है जो यह सत्यापित करता है कि इनपुट प्रूफ वैध हैं। (इस बात पर विचार करें कि ZK में वेरिफिकेशन एल्गोरिदम को स्वयं एक अंकगणितीय सर्किट के साथ मॉडल किया जा सकता है)।
ये सुधार ZK पुस्तक के बाद के भागों का विषय हैं। हालांकि, VM में एक वैध state ट्रांजिशन कैसा दिखता है, इसे मॉडल करने के पीछे की विचार प्रक्रिया आधुनिक VMs पर भी लागू होती है।
ZKVMs के बारे में और जानें
ZKVM के लिए पहला प्रस्ताव TinyRAM था, जिसे एक पेपर Snarks for C: verifying Program Executions Succinctly and in Zero Knowledge में प्रस्तावित किया गया था। लेखकों ने, अपने शब्दों में “एक न्यूनतम (minimalistic) RISC रैंडम-एक्सेस मशीन बनाई, जिसमें Harvard architecture और वर्ड-एड्रेसेबल रैंडम-एक्सेस मेमोरी है।” TinyRAM specification के लिए केवल 29 opcodes की आवश्यकता होती है।
चूंकि इस पेपर ने ZKVMs में शोध को जन्म दिया, इसलिए यह समझने योग्य एक उच्च-प्रभाव (high-impact) वाला पेपर है।
अधिकांश आधुनिक ZKVMs RISC-V आर्किटेक्चर पर आधारित हैं, लेकिन MIPS आर्किटेक्चर वाले ZKVMs भी मौजूद हैं। ZK layer 2 ब्लॉकचेन अक्सर अपने स्वयं के कस्टम आर्किटेक्चर का उपयोग करते हैं।
Scroll ने ZKEVM कैसे बनाया, इस पर Ye Zhang का video भी उच्च-स्तरीय समझ के लिए काफी उपयोगी (approachable) है।