Building zkRollup (Part — 2)

Shishir Singh
cumberlandlabs
Published in
7 min readFeb 12, 2023

This article is in continuation of Part-1. In this part, I will be focusing more on Layer-2. Primarily Layer-2 role is to bundle transactions, generate SNARK proof and trigger the state change on L1. From an architecture perspective I have divided the L2 functionality into 3 major functions:

  1. Deposit in L1
  2. ForgeBatchTransaction
  3. Withdraw from L1

Deposit in L1

This is a straightforward functionality, in which the user can directly deposit ETH on L1 against an EdDSA address. There is no L2 role to be played in this functionality; except generating EdDSA public address.

zkRollup — Deposit

l2Wallet.js is the wallet code to createEddsa public address and sign using EdDSA scheme.

const buildEddsa = require("circomlibjs").buildEddsa;
const buildMimc7 = require("circomlibjs").buildMimc7;

async function signEddsa(privateKey,messageArray) {
const eddsa = await buildEddsa();
const mimcjs = await buildMimc7();
const result = new Array(3) ;
const signature = eddsa.signMiMC(Buffer.from(privateKey),
mimcjs.multiHash(messageArray));
result[0] = mimcjs.F.toObject(signature.R8[0]).toString() ;
result[1] = mimcjs.F.toObject(signature.R8[1]).toString() ;
result[2] = (signature.S).toString() ;
return result ;
}

async function createEddsaWallet(privateKey) {
const eddsa = await buildEddsa();
const mimcjs = await buildMimc7();
const publicKey = await eddsa.prv2pub(Buffer.from(privateKey));
publicKey[0] = await mimcjs.F.toObject(publicKey[0]).toString() ;
publicKey[1] = await mimcjs.F.toObject(publicKey[1]).toString() ;
// console.log(publicKey) ;
return publicKey ;
}
module.exports = {signEddsa, createEddsaWallet};

To test the generation of EdDSA addresses and depositing ETH against these EdDSA address we can make use of the below test file:

const xponentAbi = require('../build/contracts/xponentDB.json');
const testData = require('./testData.json');
const l2Wallet = require('../src/l2Wallet');
const Web3 = require('web3');
const web3 = new Web3('http://127.0.0.1:8545');

const cordinatorPrivateKey = "0xee9d941c9b1c2d55bea4d03a7317770d568b220d629058fb8319e75723dd4d6c";
const xponentContractAddress = require('../deployData.json');
const xponentContract = new web3.eth.Contract(xponentAbi.abi, xponentContractAddress.xponentContractAddress);

const verifyProof = async () => {
const verifierAddress = '0xC16bFa72A84b73949fc714573bc0DCa81942be1E' ;
const verifierAbi = require('../build/contracts/PlonkVerifier.json');
const proof = fs.readFileSync('proof.txt', { encoding: 'utf8' });
const publicSignals = require('./publicSignals.json');
const verifierContract = new web3.eth.Contract(verifierAbi.abi, verifierAddress);
console.log(await verifierContract.methods.verifyProof(proof,publicSignals).call()) ;
}

const depositTransaction = async(publixKeyX, publixKeyY, amount) => {
const coordinatorPublicKey = testData.coordinatorPublicKey ;
const rawData = xponentContract.methods.deposit(publixKeyX, publixKeyY).encodeABI();
const nonce = await web3.eth.getTransactionCount(coordinatorPublicKey, 'latest');
const rawTxn = {
nonce: nonce,
to: xponentContractAddress.xponentContractAddress,
gas: 2000000,
value: web3.utils.toWei(amount, 'ether'),
data: rawData
}
const signedTx = await web3.eth.accounts.signTransaction(rawTxn, cordinatorPrivateKey);
await web3.eth.sendSignedTransaction(signedTx.rawTransaction, function (error, hash) {
if (!error) { console.log("🎉 The hash of your transaction is: ", hash); }
else { console.log("❗Something went wrong while submitting your transaction:", error) }
});
}

const init = async(transactionCount) => {
let publicKeyFrom = new Array (transactionCount) ;
let publicKeyTo = new Array (transactionCount) ;

for (var i = 0 ; i < transactionCount ; i++){
publicKeyFrom[i] = await l2Wallet.createEddsaWallet(testData.usersFromPrivateKey[i]);
publicKeyTo[i] = await l2Wallet.createEddsaWallet(testData.usersToPrivateKey[i]);
await depositTransaction(
publicKeyFrom[i][0],publicKeyFrom[i][1],testData.deposit[i]
);
}

// for (var i = 0 ; i < transactionCount ; i++){
// await xponentContract.methods.accounts(publicKeyFrom[i][0],publicKeyFrom[i][1]).call
// ((err, result) => { console.log(`PreRollup Balance From User
// ${publicKeyFrom[i][0]} , ${publicKeyFrom[i][1]} is : ${result.balance}`) ;})
// await xponentContract.methods.accounts(publicKeyTo[i][0],publicKeyTo[i][1]).call
// ((err, result) => { console.log(`PreRollup Balance To User ${publicKeyTo[i][0]} , ${publicKeyTo[i][1]} is : ${result.balance}`) ;})
// }

for (var i = 0 ; i < transactionCount ; i++){
await xponentContract.methods.withdraws(publicKeyFrom[i][0],publicKeyFrom[i][1]).call
((err, result) => { console.log(`Withdraw Balance for ${publicKeyFrom[i][0]} , ${publicKeyFrom[i][1]} --> ${result}`) ;})
}
}

init(2);

ForgeBatchTransaction

This is one of the primary function of Layer-2. Over here Layer-2:

  1. Store all the transactions with the EdDSA signature of the From public key
  2. Pick up 2 transactions from the mempool area. In the production-grade ZK-Rollup; the selection criteria for picking these bunch to the transactions could be based on various factors e.g. Highest Gas, Nonce or even influenced by off-chain settings. Refer here for more on this topic.
  3. Once we shortlisted the transactions, the very following action of layer-2 is to generate the zk-SNARK proof. In order to create the proof, public input is taken from the on-chain parameters like the latest root of the transaction.
  4. Layer 2 sends this transaction bundle, its public signals, and proof to L1.
  5. L1 on chain code verifies the OnChain Root and Balances. If that is found ok, it invokes the Verifier contract to verify the proof onChain.
  6. Once the proof is successfully verified, the contract will perform the state change and generate the new OnChain root.
  7. With a successful response from OnChain EVM, L2 will update its local state change, e.g storage trie.
zkRollup — ForgeBatchTransaction

There are many design choices in L2 with their respective pros and cons. The topic of L2 pros and cons is beyond this article. I will try to cover it in future series.

From a coding perspective, l2Web3.js has several functions used in ForgeBatchTransaction and its helper functions as well. For now, we can keep the withdraw-related functions to a later point of discussion.

Other LeveDB and merkle-patricia-tree-related functions represent the super miniature form of Geth. At a very high level, they are used to persist the L2 transactions and L2 state.

const xponentAbi = require('../build/contracts/xponentDB.json');
const Web3 = require('web3');
const web3 = new Web3('http://127.0.0.1:8545');
const xponentContractAddress = require('../deployData.json');
const xponentContract = new web3.eth.Contract(xponentAbi.abi, xponentContractAddress.xponentContractAddress);
const Trie = require('merkle-patricia-tree').BaseTrie;
const level = require('level').Level;
const { Account, BN, bufferToHex, rlp, keccak256 } = require('ethereumjs-util');
const db = new level('./db1') ;

async function getOnChainRoot () {
return new Promise( async resolve => {
await xponentContract.methods.transactionRoot().call
((err, result) => { return resolve(result) ; })
})
} ;

async function getOnChainWithdrawRoot () {
return new Promise( async resolve => {
await xponentContract.methods.withdrawRoot().call
((err, result) => { return resolve(result) ; })
})
} ;


async function getBalance (PubKeyX, PubKeyY) {
return new Promise( async resolve => {
await xponentContract.methods.accounts(PubKeyX, PubKeyY).call
((err, result) => { return resolve(result.balance) ; })
})
} ;

async function getTransactionNonce (PubKeyX, PubKeyY) {
return new Promise( async resolve => {
await xponentContract.methods.accounts(PubKeyX, PubKeyY).call
((err, result) => { return resolve(result.transaction_nonce) ; })
})
} ;

async function dbGet(key) {
return await db.get(key);
}
async function setGenesisRoot() {
const genesisRoot = "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421";
await dbInsertHex(0, genesisRoot); // state trie
await dbInsertHex(1, genesisRoot); // transaction trie
await dbInsertHex(2, genesisRoot); // storage trie
await dbInsertHex(3, genesisRoot); // receipt trie
}
async function trieInsert(key, value) {
var trie = new Trie(db);
await trie.put(key, value);
return trie.root;
}
async function trieDelete(key, root) {
var trie = new Trie(db, root);
await trie.del(key);
return trie.root;
}
async function trieInsertWithRoot(key, value, Root) {
var trie = new Trie(db, Root);
await trie.put(key, value);
return trie.root;
}

async function withdraw(publicSignals,proof,coordinatorPublicKey,coordinatorPrivateKey) {
// const transactionRoot = await xponentContract.methods.transactionRoot().call();
const nonce = await web3.eth.getTransactionCount(coordinatorPublicKey, 'latest');
const rawData = xponentContract.methods.withdraw(publicSignals, proof).encodeABI();
const rawTxn = {
nonce: nonce,
to: xponentContractAddress.xponentContractAddress,
gas: 3100000,
data: rawData
}
const signedTx = await web3.eth.accounts.signTransaction(rawTxn, coordinatorPrivateKey);
// console.log(signedTx) ;
const hash = await web3.eth.sendSignedTransaction(signedTx.rawTransaction) ;
return hash ;
}

async function forgeBatchOnChain(publicSignals,
proof,
coordinatorPublicKey,
coordinatorPrivateKey) {
const nonce = await web3.eth.getTransactionCount(coordinatorPublicKey, 'latest');
// const transactionRoot = await xponentContract.methods.transactionRoot().call();
const rawData = xponentContract.methods.forgeBatch(publicSignals, proof).encodeABI();
const rawTxn = {
nonce: nonce,
to: xponentContractAddress.xponentContractAddress,
gas: 3100000,
data: rawData
}
const signedTx = await web3.eth.accounts.signTransaction(rawTxn, coordinatorPrivateKey);
// console.log(signedTx) ;
const hash = await web3.eth.sendSignedTransaction(signedTx.rawTransaction) ;
// await stateUpdate(transactionObject,transactionCount,hash) ;
return hash ;
}

async function dbInsertHex(key, value) {
return await db.put(key, bufferToHex(value));
}
async function sendTransaction(transactionObject) {
if (parseInt(await getBalance(transactionObject.fromPubKeyX, transactionObject.fromPubKeyY)) > transactionObject.value) {
key = Buffer.from(rlp.encode([transactionObject.gas ,
transactionObject.fromPubKeyX,
transactionObject.fromPubKeyY,
transactionObject.nonce_from]));
value = Buffer.from(rlp.encode([transactionObject.value,
transactionObject.toPubKeyX,
transactionObject.toPubKeyY,
transactionObject.R8x,
transactionObject.R8y,
transactionObject.S,
transactionObject.nonce_to]));
const txnRootBuffer = Buffer.from((await dbGet(1)).slice(2), 'hex');
const txnRoot = await trieInsertWithRoot(key, value, txnRootBuffer);
dbInsertHex(1, txnRoot);
return bufferToHex(txnRoot);
} else
console.error("Insufficient balance !!");
}

async function getMemPoolTransaction () {
return new Promise( async resolve => {
let output = [];
let options = { limit: 2 } ;
let txnRoot = await dbGet(1);
let separator = "," ;
const txnRootBuffer = Buffer.from(txnRoot.slice(2), 'hex');
let trie = new Trie(db, txnRootBuffer);
trie.createReadStream({ options })
.on('data', data => {
output.push([ rlp.decode(data.key,true).data.toString().split(separator),
rlp.decode(data.value,true).data.toString().split(separator)]);
})
.on('end', () => { resolve(output); });
});
}

module.exports = {
sendTransaction, getBalance, dbGet,
setGenesisRoot, getMemPoolTransaction, getOnChainRoot,getTransactionNonce,
forgeBatchOnChain,getOnChainWithdrawRoot,withdraw
};
List of important L2 functions

To test ForgeBatchTransaction we can make use of l2Web3Test.js

const xponentAbi = require('../build/contracts/xponentDB.json');
const snarkjs = require("snarkjs");
const Web3 = require('web3');
const web3 = new Web3('http://127.0.0.1:8545');
const testData = require('./testData.json');
const l2Wallet = require('../src/l2Wallet');
const l2Web3 = require('../src/l2Web3.js');
const l2VM = require('../src/l2VM.js');
const transactionCount = 2;
const fs = require("fs");
const zKeyFilePath = "../build/MultipleTransaction2_circuit_final.zkey";
const wasmFilePath = "../build/MultipleTransaction2_js/MultipleTransaction2.wasm" ;
const withdrawZKeyFilePath = "../buildWithdraw/withdraw_circuit_final.zkey";
const withdrawWasmFilePath = "../buildWithdraw/Withdraw_js/Withdraw.wasm" ;

let onChainRoot ='';
const init = async() => {
// await l2Web3.setGenesisRoot() ;
onChainRoot = await l2Web3.getOnChainRoot() ;
const coordinatorPublicKey = testData.coordinatorPublicKey ;
const coordinatorPrivateKey = testData.coordinatorPrivateKey ;
let publicKeyFrom = new Array (transactionCount) ;
let publicKeyTo = new Array (transactionCount) ;
let signature = new Array (transactionCount) ;
let nonce_from = new Array (transactionCount) ;
let nonce_to = new Array (transactionCount) ;

console.log(await l2Web3.dbGet(1)) ;

for (var i = 0 ; i < transactionCount ; i++){
publicKeyFrom[i] = await l2Wallet.createEddsaWallet(testData.usersFromPrivateKey[i]);
publicKeyTo[i] = await l2Wallet.createEddsaWallet(testData.usersToPrivateKey[i]);
nonce_from[i] = await l2Web3.getTransactionNonce(publicKeyFrom[i][0],publicKeyFrom[i][1]) ;
nonce_to[i] = await l2Web3.getTransactionNonce(publicKeyTo[i][0],publicKeyTo[i][1]) ;
signature[i] = await l2Wallet.signEddsa(testData.usersFromPrivateKey[i],
[onChainRoot,publicKeyFrom[i][0],publicKeyFrom[i][1],
nonce_from[i],(520000 + i * 456).toString(),
publicKeyTo[i][0],publicKeyTo[i][1]]) ;
await l2Web3.sendTransaction({
gas: (21000 + i).toString(),
nonce_from: nonce_from[i],
fromPubKeyX: publicKeyFrom[i][0],
fromPubKeyY: publicKeyFrom[i][1],
value: (520000 + i * 456).toString(),
toPubKeyX:publicKeyTo[i][0],
toPubKeyY: publicKeyTo[i][1],
R8x: signature[i][0],
R8y:signature[i][1],
S:signature[i][2],
nonce_to: nonce_to[i],
})
}
console.log(await l2Web3.dbGet(1)) ;
let memPoolTransactions = [] ;
memPoolTransactions = (await l2Web3.getMemPoolTransaction()).slice(0, 2);
console.log(memPoolTransactions);
let proofAndPublicSignals = [] ;
proofAndPublicSignals = await l2VM.generateProof(
memPoolTransactions,
wasmFilePath,
zKeyFilePath) ;
fs.writeFileSync('proof.txt',proofAndPublicSignals[0],{ flag: 'w' });
fs.writeFileSync('publicSignals.json',JSON.stringify(proofAndPublicSignals[1]),{ flag: 'w' });

const proof = fs.readFileSync('proof.txt', { encoding: 'utf8' });
const publicSignals = require('./publicSignals.json');
console.log(await l2Web3.forgeBatchOnChain(publicSignals,
proof,coordinatorPublicKey,coordinatorPrivateKey) ) ;

console.log('Forge batch onchain Done !!');
console.log('Start generating withdraw proof..');

const initialOnChainWithdrawRoot = await l2Web3.getOnChainWithdrawRoot() ;
const withdrawPubKey = await l2Wallet.createEddsaWallet(testData.usersFromPrivateKey[0]);
const withdrawToAddress = testData.withdrawToAddress;
const withdrawSignature = await l2Wallet.signEddsa(testData.usersFromPrivateKey[0],
[withdrawPubKey[0],
withdrawPubKey[1],
withdrawToAddress,
(520000).toString(),
initialOnChainWithdrawRoot]) ;

let withdrawProofAndPublicSignals = [] ;
withdrawProofAndPublicSignals = await l2VM.generateWithdrawProof({
fromPubKeyX:withdrawPubKey[0],
fromPubKeyY: withdrawPubKey[1],
amount: (520000).toString(),
to:withdrawToAddress,
R8x: withdrawSignature[0],
R8y:withdrawSignature[1],
S:withdrawSignature[2],
},withdrawWasmFilePath,withdrawZKeyFilePath
) ;

fs.writeFileSync('WithdrawProof.txt',withdrawProofAndPublicSignals[0],{ flag: 'w' });
fs.writeFileSync('WithdrawPublicSignals.json',JSON.stringify(withdrawProofAndPublicSignals[1]),{ flag: 'w' });
console.log('Generate WithdrawProof Done Succesfully!!');
console.log(`Before withdraw balance = ${await web3.eth.getBalance(withdrawToAddress)} `) ;

const withdrawProof = fs.readFileSync('WithdrawProof.txt', { encoding: 'utf8' });
const withdrawPublicSignals = require('./WithdrawPublicSignals.json');

console.log(await l2Web3.withdraw(withdrawPublicSignals,
withdrawProof,coordinatorPublicKey,coordinatorPrivateKey) ) ;
console.log(`After withdraw balance = ${await web3.eth.getBalance(withdrawToAddress)} `) ;
}
init() ;

Part — 3

In Part-3 I will be covering the withdraw functionality from L1 chain.

--

--