昨天提到了怎麼使用ZK Rollup的方式將交易打包上鏈,來沒看的可以點這邊觀看,但是在裡面可以發現,能打包的交易都是只有轉帳的功能,如果我要轉帳的話,總得要有錢才能轉帳吧,所以最一剛開始也得要有錢進來才行,總不可能鏈下帳號樹建好的時候,所有帳號全部都在,而且也都有金額了,因此在這邊還需要解決建立鏈下帳號以及將錢從鏈上轉到鏈下的功能,除此之外,當把錢轉到鏈下的時候,總得有方法可以轉出來,不然就真的等同於把自己的錢永遠鎖在合約裡面了,因此本篇就在講述如何創建帳號、將錢從鏈上轉到鏈下以及將錢從鏈下轉回鏈上。
我們都知道在昨天講述過因為鏈下的交易簽章是使用另外一種簽章模式,方便產生出證明向鏈上做出存證,因此創建鏈下帳戶的方式其實也跟一般在以太坊創建帳戶其實一模一樣,就只要準備一對該簽帳系統的公私鑰對就好,而昨天有講過在鏈下的帳號樹裡面,每個地址因為要對應到某個帳戶,因此裡面會有存放該帳戶的公鑰,至於要怎麼讓自己本地端存放的公鑰進入到鏈下的帳號樹裡面呢?在這邊要進行鏈上跟鏈下的整合,在鏈上的部份要做好鏈上跟鏈下的對應,而在鏈下的話,則要更動帳號樹,要讓一個沒有任何帳號的葉節點新增該把公鑰。
在鏈上我們必須得要知道鏈上帳戶跟鏈下帳戶的對應關係,要讓他們的地址能連結起來,所以可以在合約中使用map來對應鏈上跟鏈下的帳戶,除此之外,有其他用戶要進入鏈下時,他的公鑰必須進入我們的帳號樹中,而現在帳號樹那些葉節點是尚未放入公鑰的鏈上也必須知道,這樣在鏈上有人要來新增的時候,就可以跟他說他在鏈下的地址,而這個也很關鍵,因為鏈上同時要讓鏈下知道有個用戶要進來鏈下的帳號樹,所以之時候也會發個Event讓鏈下知道,以便讓鏈下做出後續的操作,而當用戶要進來時,還必須先做一些檢查,像是現在鏈下的帳號樹是不是已經滿了,或者是現在這個用戶是不是已經在鏈下有帳號了,這都需要將他擋掉,因此鏈上的合約就可以多出一個新的函式,讓鏈上的用戶可以進入鏈下的帳號樹。
新增的程式片段如下:
contract ZKRollupContract {
uint256 accountNum;
uint256 accountTreeSize;
mapping(address => uint256) accountMapping;
event Register(
uint256 indexed accountId,
uint256 indexed publicKeyX,
uint256 indexed publicKeyY
);
function register(uint256 publicKeyX, uint256 publicKeyY) external {
require(accountMapping[msg.sender] == 0);
require(accountNum < accountTreeSize);
accountMapping[msg.sender] = accountNum;
emit Register(accountNum, publicKeyX, publicKeyY);
++accountNum;
}
}
在鏈下中,因為有新的帳戶進來了,所以必須將他塞進鏈下的帳號樹裡面,但是如果直接把他塞進鏈下的帳號樹,鏈下的帳號樹會跟鏈上的帳號樹狀態不一致,之後在進行打包交易的時候,證明一定會驗證不過,而透過交易也只能更改帳號樹裡面的餘額跟nonce,不能更動帳號樹葉節點公鑰的資訊,所以我們必須新增一種新的交易模式,讓他可以對鏈下的帳號樹新增帳戶,而這種交易模式並不是任何人都可以發起的,這只能由負責打包的那個人在帳號樹中有一個最高等級帳戶,只有他才可以進行新增帳戶的交易,而該帳戶只能進行這種新增帳戶的交易,而該種特別交易我定義他為註冊,而這種特別的交易格式也需要比照一般交易,只不過內容不同,交易內容如下:
將這種新的交易模式定義出來之後,代表著鏈下收到鏈上發出來要新增帳戶的Event的時候,就要新增一筆這種交易,來更改鏈下的帳號樹,但是在電路中,之前驗證交易的規則都已經寫出來了,使用該種規則來套這種交易絕對產生不出證明,所以這時候在電路中,就必須要判斷現在面對的是哪種交易,來做出不同的驗證,但在電路中,沒辦法根據不同的交易做不同的驗證,這個道理很簡單,因為證明的題目一旦公布了,要證明的東西就是固定的,不能再修改了,那怎麼辦呢?這時候就要做出一個寫電路的技巧,這種方式就是都會計算,但不一定會驗證,之前我們知道我們透過gnark的func (frontend.API).AssertIsEqual(i1 frontend.Variable, i2 frontend.Variable)
來進行驗證,而一旦走到這裡,就代表著帶入的兩個數值必須相等,那我先不進行驗證,而把結果先算出來,之後只回傳結果,之後再透過不同種的交易類型,來選擇我要驗證的結果,這聽起來很抽象,但實際情況要怎麼做呢?還記得之前我把交易的驗證另外寫對吧?這時候就要修改裡面的內容了!改成以下這樣:
func (batch *Batch) Transfer(api frontend.API) frontend.Variable {
var check frontend.Variable = 1
var one frontend.Variable = 1
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.PublicKeyX, batch.SenderAfter.PublicKeyX)))
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.PublicKeyY, batch.SenderAfter.PublicKeyY)))
check = api.And(check, api.Or(api.IsZero(api.Cmp(api.Cmp(batch.SenderBefore.Balance, batch.Amount), one)), api.IsZero(api.Cmp(batch.SenderBefore.Balance, batch.Amount))))
check = api.And(check, api.IsZero(api.Cmp(api.Add(batch.SenderAfter.Balance, batch.Amount), batch.SenderBefore.Balance)))
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.Nonce, batch.Nonce)))
check = api.And(check, api.IsZero(api.Cmp(api.Add(batch.Nonce, one), batch.SenderAfter.Nonce)))
check = api.And(check, api.IsZero(api.Cmp(batch.ReceiverBefore.PublicKeyX, batch.ReceiverAfter.PublicKeyX)))
check = api.And(check, api.IsZero(api.Cmp(batch.ReceiverBefore.PublicKeyY, batch.ReceiverAfter.PublicKeyY)))
check = api.And(check, api.IsZero(api.Cmp(api.Add(batch.ReceiverBefore.Balance, batch.Amount), batch.ReceiverAfter.Balance)))
check = api.And(check, api.IsZero(api.Cmp(batch.ReceiverBefore.Nonce, batch.ReceiverAfter.Nonce)))
// 多驗證發送帳號不能是特別帳號
var specialAccount frontend.Variable = 1
check = api.And(check, api.Cmp(batch.Sender, specialAccount))
return check
}
可以發現現在的檢查都被拔掉了,替換的就是在算出check
,而check就是把所有結果給AND起來,一旦有一個失敗,之後回傳回去就是失敗,之前透過交易來判斷該驗證哪個結果,所以這邊還需要另外寫一個驗證註冊交易的電路,程式碼如下:
func (batch *Batch) Register(api frontend.API) frontend.Variable {
var check frontend.Variable = 1
var one frontend.Variable = 1
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.PublicKeyX, batch.SenderAfter.PublicKeyX)))
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.PublicKeyY, batch.SenderAfter.PublicKeyY)))
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.Balance, batch.SenderAfter.Balance)))
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.Nonce, batch.SenderAfter.Nonce)))
check = api.And(check, api.IsZero(batch.ReceiverBefore.PublicKeyX))
check = api.And(check, api.IsZero(batch.ReceiverBefore.PublicKeyY))
check = api.And(check, api.IsZero(batch.ReceiverBefore.Balance))
check = api.And(check, api.IsZero(batch.ReceiverBefore.Nonce))
check = api.And(check, api.IsZero(api.Cmp(batch.ReceiverAfter.PublicKeyX, batch.Amount)))
check = api.And(check, api.IsZero(api.Cmp(batch.ReceiverAfter.PublicKeyY, batch.Nonce)))
check = api.And(check, api.IsZero(batch.ReceiverAfter.Balance))
check = api.And(check, api.IsZero(batch.ReceiverAfter.Nonce))
// 驗證發送帳號是不是特別帳號
var specialAccount frontend.Variable = 1
check = api.And(check, api.IsZero(api.Cmp(batch.Sender, specialAccount)))
return check
}
這時候要檢查的條件就變了,這時候要檢查的是特別帳號的葉節點內容不變,同時還要檢查塞入的葉節點是否原本是空的,以及之後是不是有好好的將公鑰塞入節點中,而在這邊多加了一個Register代表的是原本電路也需要跑這裡的結果,所以程式改動如下:
func (circuit *RollupCircuit) Define(api frontend.API) error {
var one frontend.Variable = 1
mimcHash, err := mimc.NewMiMC(api)
if err != nil {
return err
}
curve, err := twistededwards.NewEdCurve(api, cryptoTwistededwards.BN254)
if err != nil {
return err
}
batchTreeNode := []frontend.Variable{}
for i := 0; i < BatchSize; i++ {
// 改動地方
transfer := circuit.Batches[i].Transfer(api)
register := circuit.Batches[i].Register(api)
// 透過api.Select選出這次要驗證的東西
result := api.Select(api.IsZero(api.Cmp(circuit.Batches[i].Sender, one)), register, transfer)
// 驗證選出的結果
api.AssertIsEqual(result, one)
verifyMerkleProof(api, circuit.Batches[i].Sender, circuit.Batches[i].SenderBefore, circuit.Batches[i].SenderMerkleProof, circuit.AccountRootFlow[2*i], &mimcHash)
verifyMerkleProof(api, circuit.Batches[i].Sender, circuit.Batches[i].SenderAfter, circuit.Batches[i].SenderMerkleProof, circuit.AccountRootFlow[2*i+1], &mimcHash)
verifyMerkleProof(api, circuit.Batches[i].Receiver, circuit.Batches[i].ReceiverBefore, circuit.Batches[i].ReceiverMerkleProof, circuit.AccountRootFlow[2*i+1], &mimcHash)
verifyMerkleProof(api, circuit.Batches[i].Receiver, circuit.Batches[i].ReceiverAfter, circuit.Batches[i].ReceiverMerkleProof, circuit.AccountRootFlow[2*i+2], &mimcHash)
mimcHash.Reset()
var publicKey eddsa.PublicKey
publicKey.A.X = circuit.Batches[i].SenderBefore.PublicKeyX
publicKey.A.Y = circuit.Batches[i].SenderBefore.PublicKeyY
batchTreeNode = append(batchTreeNode, circuit.Batches[i].Message(&mimcHash))
err = eddsa.Verify(curve, circuit.Batches[i].Signature, batchTreeNode[i], publicKey, &mimcHash)
if err != nil {
return err
}
}
api.AssertIsEqual(circuit.AccountRootBefore, circuit.AccountRootFlow[0])
api.AssertIsEqual(circuit.AccountRootAfter, circuit.AccountRootFlow[BatchSize*2])
for i := BatchTreeHeight; i > 0; i-- {
for j, k := 0, 0; j < (1 << i); j, k = j+2, k+1 {
mimcHash.Reset()
mimcHash.Write(batchTreeNode[j], batchTreeNode[j+1])
batchTreeNode[k] = mimcHash.Sum()
}
}
api.AssertIsEqual(batchTreeNode[0], circuit.BatchRoot)
return nil
}
這樣子就可以做到在同一個電路中驗證不同種的交易,但是也代表著不論是註冊還是交易,在產生證明的時候,還是需要跑算註冊合法性跟交易合法性那段,所以額外加入一種新的交易,會讓證明產生時間便更久,而之後產生出讓資金往返鏈上跟鏈下的交易,用的都是同一個邏輯,我便不再多做贅述,就是按照上述這種方式來做,所以因為能註冊帳戶了,代表著該用戶鏈上資金能移到鏈下了,接下來繼續做將資金移到鏈下的特別交易!
現在要做讓使用者能夠將鏈上資金轉到鏈下,這邊一樣都是要做好鏈上跟鏈下的溝通,那麼分別要怎麼做,讓我們一起來看看。
將鏈上資金轉到鏈下對於使用者來說就是把錢轉去智能合約,而在鏈上合約也必須將這件事情讓鏈下知道,所以也會透過發Event的方式來讓鏈下知道,同時也要檢查這個帳戶是不是在鏈下有註冊過,由於只是使用者只是把錢轉進來,不用帶入任何東西,所以也可以使用fallback函式來操作,但我這邊是寫另外的一個函式來執行。
新增的程式碼片段如下:
contract ZKRollupContract {
event Deposit(uint256 indexed accountId, uint256 indexed amount);
function deposit() external payable {
require(accountMapping[msg.sender] != 0);
emit Deposit(accountMapping[msg.sender], msg.value);
}
}
在鏈下,跟註冊一樣都是需要一個特別的帳號去執行一個特別的交易,而我把這個帳號排在帳號樹的第一個節點,這個帳號就是根據鏈上所發出的Event來將錢分配給鏈下的帳戶,而這時候的交易內容,其實就跟一般的交易沒有兩樣,只不過特別的點在於這裡的nonce並不會用到,而且Sender必須是該個特殊帳號,有這個認知之後,基本上就可以很好寫出我們鏈下的驗證電路的部分。
程式片段如下:
func (batch *Batch) Deposit(api frontend.API) frontend.Variable {
var check frontend.Variable = 1
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.PublicKeyX, batch.SenderAfter.PublicKeyX)))
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.PublicKeyY, batch.SenderAfter.PublicKeyY)))
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.Balance, batch.SenderAfter.Balance)))
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.Nonce, batch.SenderAfter.Nonce)))
check = api.And(check, api.IsZero(api.Cmp(batch.ReceiverBefore.PublicKeyX, batch.ReceiverAfter.PublicKeyX)))
check = api.And(check, api.IsZero(api.Cmp(batch.ReceiverBefore.PublicKeyY, batch.ReceiverAfter.PublicKeyY)))
check = api.And(check, api.IsZero(api.Cmp(api.Add(batch.ReceiverBefore.Balance, batch.Amount), batch.ReceiverAfter.Balance)))
check = api.And(check, api.IsZero(api.Cmp(batch.ReceiverBefore.Nonce, batch.ReceiverAfter.Nonce)))
// 驗證發送帳號是不是特別帳號,由於該特別帳號設定是第一個,所以地址為0
check = api.And(check, api.IsZero(batch.Sender))
return check
}
然後記得在總電路的部分,做出跟做註冊交易一樣的操作,判斷方式便是看他的發送者帳戶地址是否為特殊帳戶來去進行判斷。
這個的操作就比較複雜一點,因為當用戶想要從鏈下拿錢回鏈上時,鏈下的狀態樹一定會被更動,所以也必須透過特殊的交易模式才可以執行,而在鏈上只有保留帳號樹的根雜湊值,即便是使用者提出雜湊證明時,可以證明出自己能提領多少錢,但是會造成鏈下與鏈上狀態不同步的情況發生,為了解決這種狀況,當要將錢從鏈下領回鏈上時,操作方向應該要是相反的,這時候使用者不是先向合約進行互動,而是先在鏈下做互動,發出另一種特別的交易,一旦這種特別的交易上鏈,使用者才可以拿著雜湊證明去鏈上把該領的錢給提取回去,才不會造成鏈上鏈下狀態無法調整的情況,由於方向是逆著走的,所以就從鏈下設計開始說明。
當使用者想從鏈下提錢回鏈上時,應該先在鏈下做出一個特別的交易,這個交易跟一般的交易沒差多少,只差在收款人是要填寫特別的帳號,而這支帳號就跟原本從鏈上轉錢到鏈下發錢給你的帳號一模一樣,透過這種方式,可以銷毀掉在鏈下的錢,所以基本上這種特別交易檢查的內容也跟一般的交易差不多。
鏈下電路程式片段如下:
func (batch *Batch) Withdraw(api frontend.API) frontend.Variable {
var check frontend.Variable = 1
var one frontend.Variable = 1
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.PublicKeyX, batch.SenderAfter.PublicKeyX)))
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.PublicKeyY, batch.SenderAfter.PublicKeyY)))
check = api.And(check, api.Or(api.IsZero(api.Cmp(api.Cmp(batch.SenderBefore.Balance, batch.Amount), one)), api.IsZero(api.Cmp(batch.SenderBefore.Balance, batch.Amount))))
check = api.And(check, api.IsZero(api.Cmp(api.Add(batch.SenderAfter.Balance, batch.Amount), batch.SenderBefore.Balance)))
check = api.And(check, api.IsZero(api.Cmp(batch.SenderBefore.Nonce, batch.Nonce)))
check = api.And(check, api.IsZero(api.Cmp(api.Add(batch.Nonce, one), batch.SenderAfter.Nonce)))
check = api.And(check, api.IsZero(api.Cmp(batch.ReceiverBefore.PublicKeyX, batch.ReceiverAfter.PublicKeyX)))
check = api.And(check, api.IsZero(api.Cmp(batch.ReceiverBefore.PublicKeyY, batch.ReceiverAfter.PublicKeyY)))
check = api.And(check, api.IsZero(api.Cmp(batch.ReceiverBefore.Balance, batch.ReceiverAfter.Balance)))
check = api.And(check, api.IsZero(api.Cmp(batch.ReceiverBefore.Nonce, batch.ReceiverAfter.Nonce)))
// 驗證收取帳號是不是特別帳號,由於該特別帳號設定是第一個,所以地址為0
check = api.And(check, api.IsZero(batch.Receiver))
return check
}
然後記得在總電路的部分,做出跟做註冊交易一樣的操作,判斷方式便是看他的收取者帳戶地址是否為特殊帳戶來去進行判斷。
而當負責打包的人,將這筆交易傳去鏈上的時候,因為每次都會在鏈上存取交易樹的根雜湊值,而負責打包的人也需要公開自己打包了那些交易,所以使用者可以自己算出雜湊證明,向鏈上證明出自己確實有做過該筆交易,那個時間點,合約便可以確實的將該筆金額送給使用者鏈上的帳戶。
但在鏈下是使用ZK-friendly的雜湊算法,之前有介紹過ZK-friendly是使用許多加法跟乘法弄出來的,而用加法跟乘法做出的雜湊在鏈上會消耗大量的手續費,所以用上述這種方式來將錢從合約上轉走,會消耗過多的手續費,既然是要使用者提出雜湊證明,然後鏈上檢驗,那會什麼不要直接讓使用者提出零知識證明,接著鏈上驗證這個證明呢?這樣可以減少手續費的消耗。
要讓使用者算出零知識證明的電路如下:
type WithdrawCircuit struct {
Sender frontend.Variable `gnark:"sender,public"`
Receiver frontend.Variable `gnark:"receiver"`
Amount frontend.Variable `gnark:"amount,public"`
Nonce frontend.Variable `gnark:"nonce,public"`
BatchIndex frontend.Variable `gnark:"batchIndex"`
BatchMerkleProof [BatchTreeHeight]frontend.Variable `gnark:"batchMerkleProof"`
BatchRoot frontend.Variable `gnark:"batchRoot,public"`
}
func (circuit *WithdrawCircuit) Define(api frontend.API) error {
mimcHash, err := mimc.NewMiMC(api)
if err != nil {
return err
}
mimcHash.Reset()
binary := api.ToBinary(circuit.BatchIndex, BatchTreeHeight)
mimcHash.Write(circuit.Sender, circuit.Receiver, circuit.Amount, circuit.Nonce)
sum := mimcHash.Sum()
for j := 0; j < BatchTreeHeight; j++ {
mimcHash.Reset()
left := api.Select(binary[j], circuit.BatchMerkleProof[j], sum)
right := api.Select(binary[j], sum, circuit.BatchMerkleProof[j])
mimcHash.Write(left, right)
sum = mimcHash.Sum()
}
api.AssertIsEqual(circuit.BatchRoot, mimcHash.Sum())
return nil
}
這基本上就是在驗證該筆交易確實在交易樹裡,而在算出證明的時候,使用者必須拿鏈上的驗證鑰匙對應到的證明鑰匙來算,因此負責打包的人要先跑出該電路的證明鑰匙跟驗證鑰匙,接著將這兩個公開,來讓使用者可以產生出對應到的證明去向鏈上做驗證,接著拿到該拿回的錢,而為了避免重放攻擊(replay attack),這時候nonce就該公開出來當作公開資料,每當在鏈上提款時,鏈上必須記錄該nonce已經被用過,使用者不能再對該nonce進行提款,而做為公開資料的還有發送者、金額跟該次交易樹的根雜湊值,要金額是因為要知道智能合約要匯多少錢給使用者,而發送者跟交易樹的根雜湊值跟之前一樣都是要透過鏈上合約去帶入,避免使用者自己算出一個可以被驗證的證明,提取出不該提出的錢。
以下是智能合約的片段內容:
interface IVerifyWithdrawContract {
function verifyProof(
uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory c,
uint256[4] memory input
) external view returns (bool);
}
contract ZKRollupContract {
IVerifyWithdrawContract withdrawVerify;
mapping(address => mapping(uint256 => bool)) withdrawRecord;
function withdraw(
uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory c,
uint256 batchNumber,
uint256 amount,
uint256 nonce
) external {
require(accountMapping[msg.sender] != 0);
require(!withdrawRecord[msg.sender][nonce]);
require(batchNumber < batchRoot.length);
uint256[4] memory input;
input[0] = accountMapping[msg.sender];
input[1] = amount;
input[2] = nonce;
input[3] = batchRoot[batchNumber];
if (withdrawVerify.verifyProof(a, b, c, input)) {
withdrawRecord[msg.sender][nonce] = true;
payable(msg.sender).transfer(amount);
}
}
}
好了,實作完以上功能之後,基本上一個小型的ZK Rollup就已經算是完成了,但是這裡面還是存在著許多問題,因為打包交易的人手中握有兩個特別帳號的私鑰,所以他可以隨便包註冊以及存款的特別交易,使用上面的這個例子,並無法防治這種情況,而通常這種情況都是用智能合約來解決,因為之前說過,Rollup的本質是透過失去去中化化換來可擴展性,在失去去中化化的同時,就免不了這個負責打包交易的人亂來。
但實際上這是有解法的,既然Rollup無法解決,就使用智能合約的方式解,基本上就是鏈上每做一次註冊或存款,會發一個號碼牌,而這個號碼牌要作為公開資料進行證明,智能合約在Rollup的時候,必須去檢查這些號碼牌必須是有發出去的,且只能包一次,諸如此類的問題還有像是如果使用者進行存款,但打包交易的人遲遲不把這個存款的Event換成是交易放進鏈下的時候該怎麼辦,通常這些問題只能使用智能合約去解決,但要實作出這些細節耗費的功力會非常大,基於淺談的原則,我就把基本的ZK Rollup帶給大家,大家也可以依照上面的流程做出一個小型的ZK Rollup,在實作的過程中,更能去體會ZK Rollup的精髓,相信大家也在我帶大家實作的過程中,又更加理解ZK Rollup。而當程式做完後,就可以來看看成果長什麼樣子了,至於結果如何,且待明天揭曉。