Write an app-specific plasma that preserves privacy
Introduction
In contrast to rollups, plasmas use the Ethereum mainnet for integrity, but not availability. In this article, we write an application that acts like a plasma, with Ethereum guaranteeing integrity (nobody can make unauthorized changes), but not availability (there is a centralized component that can go down and disable the whole system).
The application we write here is a privacy-preserving bank. Different addresses have accounts with balances, and they can send money (ETH) to other accounts. The bank posts hashes of the state (accounts and their balances) and transactions, but keeps the actual balances offchain where they can stay private.
Design
This is not a production-ready system, but a teaching tool. As such, it is written with a number of simplifying assumptions.
-
Fixed account pool. There is a specific number of accounts, which belong to predetermined addresses. This makes for a much simpler system because it is difficult to handle variable size data structures in zero-knowledge proofs. For a production-ready system, we can use the Merkle root as the state hash and provide Merkle proofs for the balances we need.
-
Memory storage. On a production system we need to write all the account balances to disk to preserve them in case of a restart. Here it's OK if the information is simply lost.
-
Transfers only. A production system would require a way to deposit assets into the bank and to withdraw them back. But the purpose here is just to illustrate the concept, so this bank is limited to transfers.
Zero-knowledge proofs
At a very basic level, a zero-knowledge proof shows that the prover knows some data, Dataprivate such that there is a relationship Relationship between some public data, Datapublic, and Dataprivate. The verifier knows Relationship and Datapublic.
To preserve privacy, we need the states and the transactions to be private. But to ensure integrity, we need the cryptographic hash of states to be public. To prove to people who submit transactions that those transactions really happened, we need to also post transaction hashes.
In most cases, Dataprivate is the input to the zero-knowledge proof program, and Datapublic is the output.
These fields in Dataprivate:
- Staten, the old state
- Staten+1, the new state
- Transaction, a transaction that changes from the old state to the new one. This transaction needs to include these fields:
- Destination address that receives the transfer
- Amount being transferred
- Nonce to ensure each transaction can only be processed once. The source address does not need to be in the transaction, because it can be recovered from the signature.
- Signature, a signature that is authorized to perform the transaction. In our case, the only address authorized to perform a transaction is the source address. Because of the way our zero-knowledge system works, in addition to the Ethereum signature we also need the account's public key.
These are the fields in Datapublic:
- Hash(Staten) the hash of the old state
- Hash(Staten+1) the hash of the new state
- Hash(Transaction) the hash of the transaction that changes the state from Staten to Staten+1.
The relationship checks several conditions:
- The public hashes are indeed the correct hashes for the private fields.
- The transaction, when applied to the old state, results in the new state.
- The signature comes from the transaction's source address.
Because of the properties of cryptographic hash functions, proving these conditions is enough to ensure integrity.
Data structures
The main data structure is the state held by the server. For every account, the server keeps track of the account balance and a nonce, used to prevent replay attacks.
Components
This system requires two components: One is a server that receives transactions, processes them, and posts hashes to the chain along with the zero-knowledge proofs. The second is a smart contract that stores the hashes and verifies the zero-knowledge proofs to ensure state transitions are legitimate.
Data and control flow
These are the ways that the various components communicate to transfer from one account to another.
-
A web browser submits a signed transaction asking for a transfer from the signer's account to a different account.
-
The server verifies that the transaction is valid:
- The signer has an account in the bank with a sufficient balance.
- The recipient has an account in the bank.
-
The server calculates the new state by subtracting the transferred amount from the signer's balance and adding it to the recipient's balance.
-
The server calculates a zero-knowledge proof that the state change is a valid one.
-
The server submits to Ethereum a transaction that includes:
- The new state hash
- The transaction hash (so the transaction sender can know it has been processed)
- The zero-knowledge proof that proves the transition to the new state is valid
-
The smart contract verifies the zero-knowledge proof.
-
If the zero-knowledge proof checks out, the smart contract performs these actions:
- Update the current state hash to the new state hash
- Emit a log entry with the new state hash and the transaction hash
Tools
For the client-side code we are going to use Vite, React, Viem and Wagmi. These are industry standard tools, if you are not familiar with them, you can use this tutorial.
The majority of the server is written in JavaScript using Node. The zero-knowledge part is written in Noir. We need version 1.0.0-beta.10, so after you install Noir as instructed, run:
1noirup -v 1.0.0-beta.10The blockchain we use is anvil, a local testing blockchain which is part of Foundry.
Implementation
Because this is a complex system, we'll implement it in stages.
Stage 1 - Manual zero knowledge
For the first stage, we'll sign a transaction in the browser and then manually provide the information to the zero-knowledge proof. The zero-knowledge code expects to get that information in server/noir/Prover.toml (documented here).
To see it in action:
-
Make sure you have Node and Noir installed. Preferably, install them on a UNIX system such MacOS, Linux, or WSL.
-
Download the stage 1 code and start the web server to serve the client code.
1git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk2cd 250911-zk-bank3cd client4npm install5npm run devThe reason you need a web server here is that to prevent certain types of fraud many wallets (such as MetaMask) don't accept files served directly from the disk.
-
Open a browser with a wallet.
-
In the wallet enter a new passphrase. Note that this will delete your existing passphrase, so make sure you have a backup.
The passphrase is
test test test test test test test test test test test junk, the default testing passphrase for anvil. -
Browse to the client-side code.
-
Connect to the wallet and select your destination account and amount.
-
Click Sign and sign the transaction.
-
Under the Prover.toml heading you'll find text. Replace
server/noir/Prover.tomlwith that text. -
Execute the zero-knowledge proof.
1cd ../server/noir2nargo executeThe output should be similar to
1ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute23[zkBank] Circuit witness successfully solved4[zkBank] Witness saved to target/zkBank.gz5[zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85) -
Compare the last two values to the hash you see on the web browser to see the message is hashed correctly.
server/noir/Prover.toml
This file shows the information format expected by Noir.
1message="send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 500 finney (milliEth) 0 "The message is in text format, which makes it easy for the user to understand (necessary when signing) and for the Noir code to parse. The amount is quoted in finneys to enable fractional transfers on one hand, and be easily readable on the other. The last number is the nonce.
The string is 100 characters long. Zero-knowledge proofs don't handle variable size data well, so it's often necessary to pad data.
1pubKeyX=["0x83",...,"0x75"]2pubKeyY=["0x35",...,"0xa5"]3signature=["0xb1",...,"0x0d"]These three parameters are fixed-size byte arrays.
1[[accounts]]2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"3balance=100_0004nonce=056[[accounts]]7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"8balance=100_0009nonce=0అన్నీ చూపించుThis is the way to specify an array of structures. For each entry, we specify the address, balance (in milliETH a.k.a. Finney), and next nonce value.
client/src/Transfer.tsx
This file implements the client-side processing and generates the server/noir/Prover.toml file (the one that includes the zero-knowledge parameters).
Here is the explanation of the more interesting parts.
1export default attrs => {This function creates the Transfer React component which other files can import.
1 const accounts = [2 "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",3 "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",4 "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",5 "0x90F79bf6EB2c4f870365E785982E1f101E93b906",6 "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",7 ]These are the account addresses, the addresses created by the test ... test junk passphrase. If you want to use your own addresses, just modify this definition.
1 const account = useAccount()2 const wallet = createWalletClient({3 transport: custom(window.ethereum!)4 })These Wagmi hooks let us access the viem library and the wallet.
1 const message = `send ${toAccount} ${ethAmount*1000} finney (milliEth) ${nonce}`.padEnd(100, " ")This is the message, padded with spaces. Every time one of the useState variables changes, the component is redrawn and message is updated.
1 const sign = async () => {This function is called when the user clicks the Sign button. The message is updated automatically, but the signature requires user approval in the wallet, and we don't want to ask for that except when needed.
1 const signature = await wallet.signMessage({2 account: fromAccount,3 message,4 })Ask the wallet to sign the message.
1 const hash = hashMessage(message)Get the message hash. It is useful to provide it to the user for debugging (of the Noir code).
1 const pubKey = await recoverPublicKey({2 hash,3 signature4 })Get the public key. This is required for the Noir ecrecover function.
1 setSignature(signature)2 setHash(hash)3 setPubKey(pubKey)Set the state variables. Doing this redraws the component (after the sign function exits) and shows the user the updated values.
1 let proverToml = `The text for Prover.toml.
1message="${message}"23pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}4pubKeyY=${hexToArray(pubKey.slice(4+2*32))}Viem provides us the public key as a 65-byte hexadecimal string. The first byte is 0x04, a version marker. This is followed by 32 bytes for the x of the public key and then 32 bytes for the y of the public key.
However, Noir expects to get this information as two-byte arrays, one for x and one for y. It is easier to parse it here on the client rather than as part of the zero-knowledge proof.
Note that this is good practice in zero-knowledge in general. Code inside a zero-knowledge proof is expensive, so any processing that can be done outside of the zero-knowledge proof should be done outside the zero-knowledge proof.
1signature=${hexToArray(signature.slice(2,-2))}The signature is also provided as a 65-byte hexadecimal string. However, the last byte is only necessary to recover the public key. As the public key is already going to be provided to the Noir code, we don't need it to verify the signature, and the Noir code does not require it.
1${accounts.map(accountInProverToml).reduce((a,b) => a+b, "")}2`Provide the accounts.
1 setProverToml(proverToml)2 }34 return (5 <>6 <h2>Transfer</h2>This is the HTML (more accurately, JSX) format of the component.
server/noir/src/main.nr
This file is the actual zero-knowledge code.
1use std::hash::pedersen_hash;Pedersen hash is provided with the Noir standard library. This hash function is commonly used by zero-knowledge proofs. It is a lot easier to calculate inside aritmetic circuits compared to the standard hash functions.
1use keccak256::keccak256;2use dep::ecrecover;These two functions are external libraries, defined in Nargo.toml. They are exactly what they are named for, a function that calculates the keccak256 hash and a function that verifies Ethereum signatures and recovers the signer's Ethereum address.
1global ACCOUNT_NUMBER : u32 = 5;Noir is inspired by Rust. Variables, by default, are constants. This is how we define global constants for the configuration. This is the number of accounts we store.
Data types named u<number> are that number of bits, unsigned. The only supported types are u8, u16, u32, u64, and u128.
1global FLAT_ACCOUNT_FIELDS : u32 = 2;This variable is used for the Pedersen hash of the accounts, as explained below.
1global MESSAGE_LENGTH : u32 = 100;As explained above, the message length is fixed. It is specified here.
1global ASCII_MESSAGE_LENGTH : [u8; 3] = [0x31, 0x30, 0x30];2global HASH_BUFFER_SIZE : u32 = 26+3+MESSAGE_LENGTH; EIP-191 signatures require a buffer with a 26-byte prefix, followed by the message length in ASCII, and finally the message itself.
1struct Account {2 balance: u128,3 address: Field,4 nonce: u32,5}The information we store about an account. Field is a number, typically up to 253 bits, that can be used directly in the arithmetic circuit that implements the zero-knowledge proof. Here we use the Field to store a 160-bit Ethereum address.
1struct TransferTxn {2 from: Field,3 to: Field,4 amount: u128,5 nonce: u326}The information we store for a transfer transaction.
1fn flatten_account(account: Account) -> [Field; FLAT_ACCOUNT_FIELDS] {A function definition. The parameter is Account information. The result is an array of Field variables, whose length is FLAT_ACCOUNT_FIELDS
1 let flat = [2 account.address,3 ((account.balance << 32) + account.nonce.into()).into(),4 ];The first value is the array is the account address. The second includes both the balance and the nonce. The .into() calls change a number to the data type it needs to be. account.nonce is a u32 value, but to add it to account.balance << 32, a u128 value, it needs to be a u128. That's the first .into(). The second one turns the u128 result into a Field so it will fit into the array.
1 flat2}In Noir functions can only return a value at the end (there is no early return). To specify the return value, you evaluate it just before the function's closing bracket.
1fn flatten_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] {This function turns the accounts array into a Field array, which can be used as the input to a Petersen Hash.
1 let mut flat: [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] = [0; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER];This is the way you specify a variable that is mutable, that is not a constant. Variables in Noir need to always have a value, so we initialize it to all zeros.
1 for i in 0..ACCOUNT_NUMBER {This is a for loop. Note that the boundaries are constants. Noir loops have to have their boundaries known at compile time. The reason is that arithmetic circuits don't support flow control. When processing a for loop, the compiler simply puts the code inside it multiple times, one for each iteration.
1 let fields = flatten_account(accounts[i]);2 for j in 0..FLAT_ACCOUNT_FIELDS {3 flat[i*FLAT_ACCOUNT_FIELDS + j] = fields[j];4 }5 }67 flat8}910fn hash_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> Field {11 pedersen_hash(flatten_accounts(accounts))12}అన్నీ చూపించుFinally, we got to the function that hashes the accounts array.
1fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {2 let mut account : u32 = ACCOUNT_NUMBER;34 for i in 0..ACCOUNT_NUMBER {5 if accounts[i].address == address {6 account = i;7 }8 }This function finds the account with a specific address. This function would be terribly inefficient in normal code, because it iterates over all the accounts, even if it already found the address.
However, in zero-knowledge proofs there is no flow control. If we ever need to check a condition, we have to check it every time.
A similar thing happens with if statements. The if statement inside the loop above gets translated to these mathematical statements.
conditionresult = accounts[i].address == address // one if they are equal, zero otherwise
accountnew = conditionresult*i + (1-conditionresult)*accountold
1 assert (account < ACCOUNT_NUMBER, f"{address} does not have an account");23 account4}The assert function causes the zero-knowledge proof to crash if the assertion is false. In this case, if we can't find an account with the relevant address. To report the address, we use a format string.
1fn apply_transfer_txn(accounts: [Account; ACCOUNT_NUMBER], txn: TransferTxn) -> [Account; ACCOUNT_NUMBER] {This function applies a transfer transaction, and returns the new accounts array.
1 let from = find_account(accounts, txn.from);2 let to = find_account(accounts, txn.to);34 let (txnFrom, txnAmount, txnNonce, accountNonce) = 5 (txn.from, txn.amount, txn.nonce, accounts[from].nonce);We cannot access structure elements inside a format string in Noir, so we create a usable copy.
1 assert (accounts[from].balance >= txn.amount, 2 f"{txnFrom} does not have {txnAmount} finney");34 assert (accounts[from].nonce == txn.nonce,5 f"Transaction has nonce {txnNonce}, but the account is expected to use {accountNonce}"); These are two conditions that could render a transaction invalid.
1 let mut newAccounts = accounts;23 newAccounts[from].balance -= txn.amount;4 newAccounts[from].nonce += 1;5 newAccounts[to].balance += txn.amount;67 newAccounts8}Create the new accounts array and then return it.
1fn readAddress(messageBytes: [u8; MESSAGE_LENGTH]) -> Field This function reads the address from the message.
1{2 let mut result : Field = 0;34 for i in 7..47 {The address is always 20 bytes (a.k.a. 40 hexadecimal digits) long, and starts at character #7.
1 result *= 0x10;2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-93 result += (messageBytes[i]-48).into();4 }5 if messageBytes[i] >= 65 & messageBytes[i] <= 70 { // A-F6 result += (messageBytes[i]-65+10).into()7 }8 if messageBytes[i] >= 97 & messageBytes[i] <= 102 { // a-f9 result += (messageBytes[i]-97+10).into()10 } 11 } 1213 result14}1516fn readAmountAndNonce(messageBytes: [u8; MESSAGE_LENGTH]) -> (u128, u32)అన్నీ చూపించుRead the amount and nonce from the message.
1{2 let mut amount : u128 = 0;3 let mut nonce: u32 = 0;4 let mut stillReadingAmount: bool = true;5 let mut lookingForNonce: bool = false;6 let mut stillReadingNonce: bool = false;In the message, the first number after the address is the amount of Finneys (a.k.a. thousandth of an ETH) to transfer. The second number is the nonce. Any text between them is ignored.
1 for i in 48..MESSAGE_LENGTH {2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-93 let digit = (messageBytes[i]-48);45 if stillReadingAmount {6 amount = amount*10 + digit.into();7 }89 if lookingForNonce { // We just found it10 stillReadingNonce = true;11 lookingForNonce = false;12 }1314 if stillReadingNonce {15 nonce = nonce*10 + digit.into();16 }17 } else {18 if stillReadingAmount {19 stillReadingAmount = false;20 lookingForNonce = true;21 }22 if stillReadingNonce {23 stillReadingNonce = false;24 }25 }26 }2728 (amount, nonce)29}అన్నీ చూపించుReturning a tuple is the Noir way to return multiple values from a function.
1fn readTransferTxn(message: str<MESSAGE_LENGTH>) -> TransferTxn 2{3 let mut txn: TransferTxn = TransferTxn { from: 0, to: 0, amount:0, nonce:0 };4 let messageBytes = message.as_bytes();56 txn.to = readAddress(messageBytes);7 let (amount, nonce) = readAmountAndNonce(messageBytes);8 txn.amount = amount;9 txn.nonce = nonce;1011 txn12}అన్నీ చూపించుThis function actually turns the message into bytes and then turns the amounts into a TransferTxn.
1// The equivalent to Viem's hashMessage2// https://viem.sh/docs/utilities/hashMessage#hashmessage3fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {We were able to use Pedersen Hash for the accounts because they are only hashed inside the zero-knowledge proof. However, here we need to check the signature on the message, which originates from the browser. For that, we need to follow the Ethereum signing format in EIP 191. This means we need to create a combined buffer with a standard prefix, the message length in ASCII, and then the message - and use the Ethereum standard, keccak256, to hash it.
1 // ASCII prefix2 let prefix_bytes = [3 0x19, // \x194 0x45, // 'E'5 0x74, // 't'6 0x68, // 'h'7 0x65, // 'e'8 0x72, // 'r'9 0x65, // 'e'10 0x75, // 'u'11 0x6D, // 'm'12 0x20, // ' '13 0x53, // 'S'14 0x69, // 'i'15 0x67, // 'g'16 0x6E, // 'n'17 0x65, // 'e'18 0x64, // 'd'19 0x20, // ' '20 0x4D, // 'M'21 0x65, // 'e'22 0x73, // 's'23 0x73, // 's'24 0x61, // 'a'25 0x67, // 'g'26 0x65, // 'e'27 0x3A, // ':'28 0x0A // '\n'29 ];అన్నీ చూపించుTo avoid cases where an application asks the user to sign a message that can be used as a transaction or for some other purpose, EIP 191 specifies that all signed messages start with character 0x19 (not a valid ASCII character) followed by Ethereum Signed Message: and a newline.
1 let mut buffer: [u8; HASH_BUFFER_SIZE] = [0u8; HASH_BUFFER_SIZE];2 for i in 0..26 {3 buffer[i] = prefix_bytes[i];4 }56 let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();78 if MESSAGE_LENGTH <= 9 {9 for i in 0..1 {10 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];11 }1213 for i in 0..MESSAGE_LENGTH {14 buffer[i+26+1] = messageBytes[i];15 }16 }1718 if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {19 for i in 0..2 {20 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];21 }2223 for i in 0..MESSAGE_LENGTH {24 buffer[i+26+2] = messageBytes[i];25 }26 }2728 if MESSAGE_LENGTH >= 100 {29 for i in 0..3 {30 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];31 }3233 for i in 0..MESSAGE_LENGTH {34 buffer[i+26+3] = messageBytes[i];35 }36 }3738 assert(MESSAGE_LENGTH < 1000, "Messages whose length is over three digits are not supported");అన్నీ చూపించుHandle message lengths up to 999, and fail if it's more than that. I added this code, even though the message length is a constant, because it makes it easier to change it. On a production system you'd probably just assume MESSAGE_LENGTH doesn't change for the sake of better performances
1 keccak256::keccak256(buffer, HASH_BUFFER_SIZE)2}Use the Ethereum standard keccak256 function.
1fn signatureToAddressAndHash(2 message: str<MESSAGE_LENGTH>, 3 pubKeyX: [u8; 32],4 pubKeyY: [u8; 32],5 signature: [u8; 64]6 ) -> (Field, Field, Field) // address, first 16 bytes of hash, last 16 bytes of hash 7{This function verifies the signature, which requires the message hash. It then provides us with the address that signed it, and the message hash. The message hash is provided in two Field values because those are easier to use in the rest of the program than a byte array.
We need to use two Field values because field calculations are done modulu some big number, but one that is typically less than 256 bits (otherwise it would be hard to do those calculations in the EVM).
1 let hash = hashMessage(message);23 let mut (hash1, hash2) = (0,0);45 for i in 0..16 {6 hash1 = hash1*256 + hash[31-i].into();7 hash2 = hash2*256 + hash[15-i].into();8 }Specify hash1 and hash2 as mutable variables, and write the hash into them byte by byte.
1 (2 ecrecover::ecrecover(pubKeyX, pubKeyY, signature, hash), This is similar to Solidity's ecrecover, with two important differences:
- If the signature is not valid, the call fails an
assertand the program is aborted. - While the public key can be recovered from the signature and the hash, this is processing that can be done externally and therefore is not worth doing inside the zero-knowledge proof. If somebody tries to cheat us here, the signature verification will fail.
1 hash1,2 hash23 )4}56fn main(7 accounts: [Account; ACCOUNT_NUMBER], 8 message: str<MESSAGE_LENGTH>, 9 pubKeyX: [u8; 32],10 pubKeyY: [u8; 32], 11 signature: [u8; 64], 12 ) -> pub (13 Field, // Hash of old accounts array14 Field, // Hash of new accounts array15 Field, // First 16 bytes of message hash16 Field, // Last 16 bytes of message hash17 )అన్నీ చూపించుFinally, we reach the main function. We need to prove that we have a transaction that validly change the accounts has from the old value to the new one. We also need to prove that it has this specific transaction hash, so the person who sent it will know their transaction has been processed.
1{2 let mut txn = readTransferTxn(message);We need txn to be mutable because we don't read the from address from the message, we read it from the signature.
1 let (fromAddress, txnHash1, txnHash2) = signatureToAddressAndHash(2 message,3 pubKeyX,4 pubKeyY,5 signature);67 txn.from = fromAddress; 89 let newAccounts = apply_transfer_txn(accounts, txn);1011 (12 hash_accounts(accounts),13 hash_accounts(newAccounts), 14 txnHash1,15 txnHash216 )17}అన్నీ చూపించుStage 2 - Adding a server
In the second stage we add a server that receives and implements transfer transactions from the browser.
To see it in action:
-
Stop Vite if it is running.
-
Download the branch with the server and ensure you have all the necessary modules.
1git checkout 02-add-server2cd client3npm install4cd ../server5npm installThe is no need to compile the Noir code, it is the same as the code you used for stage 1.
-
Start the server.
1npm run start -
In a separate command-line window, run Vite to serve the browser code.
1cd client2npm run dev -
Browse to the client code at http://localhost:5173
-
Before you can issue a transaction, you need to know the nonce, as well as the amount you can send. To get this information, click Update account data and sign the message.
We have a dilemma here. On one hand, we don't want to sign a message that can be used multiple times (this is known as a replay attack), that is the reason we want a nonce at the first place. However, we don't have a nonce yet. The solution is to choose a nonce that can only be used once, but that we already have on both sides, such as the time.
The problem with this solution is that the time might not be perfectly synchronized. So instead we sign the a value that changes every minute. This means that our window of vulnerability to replay attacks in at most one minute. Considering that in production the signed request will be protected by TLS, and that the other side of the tunnel, the server, can already disclose the balance and nonce (it has to know them to work), this is an acceptable risk.
-
Once the browser gets back the balance and nonce, it shows the transfer form. Select the destination address and the amount and click Transfer. Sign this request.
-
To see the transfer, either Update account data or look in the window where you run the server. The server logs the state every time it changes.
1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start23> server@1.0.0 start4> node --experimental-json-modules index.mjs56Listening on port 30007Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed8New state:90xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 64000 (1)100x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 100000 (0)110x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)120x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)130x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)14Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 processed15New state:160xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 56800 (2)170x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)180x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)190x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)200x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)21Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 processed22New state:230xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 53800 (3)240x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)250x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)260x90F79bf6EB2c4f870365E785982E1f101E93b906 has 139000 (0)270x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)అన్నీ చూపించు
server/index.mjs
This file contains the server process, and interacts with the Noir code at main.nr. Here is an explanation of the interesting parts.
1import { Noir } from '@noir-lang/noir_js'The noir.js library interfaces between JavaScript code and Noir code.
1const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))2const noir = new Noir(circuit)Load the arithmetic circuit, the compiled Noir program we created in the previous stage, and prepare to execute it.
1// We only provide account information in return to a signed request2const accountInformation = async signature => {3 const fromAddress = await recoverAddress({4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),5 signature6 })To provide account information we only need the signature. The reason is we already know what the message is going to be, and therefore the message hash.
1const processMessage = async (message, signature) => {Process a message and execute the transaction it encodes.
1 // Get the public key2 const pubKey = await recoverPublicKey({3 hash,4 signature5 })Now that we run JavaScript code on the server, we can get the public key there, rather than on the client.
1 let noirResult2 try {3 noirResult = await noir.execute({4 message,5 signature: signature.slice(2,-2).match(/.{2}/g).map(x => `0x${x}`),6 pubKeyX,7 pubKeyY,8 accounts: Accounts9 })అన్నీ చూపించుnoir.execute runs the Noir program. The parameters are equivalent to those provided in Prover.toml. Note that long values are provided as an array of hexadecimal strings (["0x60", "0xA7"]), not as a single hexadecimal value (0x60A7) the way Viem does it.
1 } catch (err) {2 console.log(`Noir error: ${err}`)3 throw Error("Invalid transaction, not processed")4 }If there is an error, catch it and then relay a simplified version to the client.
1 Accounts[fromAccountNumber].nonce++2 Accounts[fromAccountNumber].balance -= amount3 Accounts[toAccountNumber].balance += amountApply the transaction. We already did it in the Noir code, but it's easier to do it again here rather than extract the result from there.
1let Accounts = [2 {3 address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",4 balance: 5000,5 nonce: 0,6 },The initial Accounts structure.
Stage 3 - Ethereum smart contracts
-
Stop the server and client processes.
-
Download the branch with the smart contracts and ensure you have all the necessary modules.
1git checkout 03-smart-contracts2cd client3npm install4cd ../server5npm install -
Run
anvilin a separate command-line window. -
Generate the verification key and the solidity verifier, then copy the verifier code to the Solidity project.
1cd noir2bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak3bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol4cp target/Verifier.sol ../../smart-contracts/src -
Go to the smart contracts and set the environment variables to use the
anvilblockchain.1cd ../../smart-contracts2export ETH_RPC_URL=http://localhost:85453ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -
Deploy
Verifier.soland store the address in an environment variable.1VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`2echo $VERIFIER_ADDRESS -
Deploy the
ZkBankcontract.1ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`2echo $ZKBANK_ADDRESSThe
0x199..67bvalue is the Pederson hash of the initial state ofAccounts. If you modify this initial state inserver/index.mjs, you can run a transaction to see the initial hash reported by the zero-knowledge proof. -
Run the server.
1cd ../server2npm run start -
Run the client in a different command-line window.
1cd client2npm run dev -
Run some transactions.
-
To verify that the state changed onchain, restart the server process. See that
ZkBankno longer accepts transactions, because the original hash value in the transactions differs from the hash value stored onchain.This is the type of error expected.
1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start23> server@1.0.0 start4> node --experimental-json-modules index.mjs56Listening on port 30007Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason:8Wrong old state hash910Contract Call:11 address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F051212 function: processTransaction(bytes _proof, bytes32[] _publicInputs)13 args: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000అన్నీ చూపించు
server/index.mjs
The changes in this file relate mostly to creating the actual proof and submitting it onchain.
1import { exec } from 'child_process'2import util from 'util'34const execPromise = util.promisify(exec)We need to use the Barretenberg package to create the actual proof to send onchain. We can use this package either by running the command-line interface (bb), or using the JavaScript library, bb.js. The JavaScript library is much slower than running code natively, so we use exec here to use the command-line.
Note that if you do decide to use bb.js, you need to use a version that is compatible with the version of Noir you are using. For the current Noir version at writing (1.0.0-beta.11), this is version 0.87.
1const zkBankAddress = process.env.ZKBANK_ADDRESS || "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"The address here is the address you get when you start with a clean anvil and go through the directions above.
1const walletClient = createWalletClient({ 2 chain: anvil, 3 transport: http(), 4 account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")5})This private key is one of the default pre-funded accounts in anvil.
1const generateProof = async (witness, fileID) => {Generate a proof using the bb executable.
1 const fname = `witness-${fileID}.gz` 2 await fs.writeFile(fname, witness)Write the witness to a file
1 await execPromise(`bb prove -b ./noir/target/zkBank.json -w ${fname} -o ${fileID} --oracle_hash keccak --output_format fields`)Actually create the proof. This step also creates a file with the public variables, but we don't need that. We already got those variables from noir.execute.
1 const proof = "0x" + JSON.parse(await fs.readFile(`./${fileID}/proof_fields.json`)).reduce((a,b) => a+b, "").replace(/0x/g, "")The proof is written as a JSON array of Field values, written as hexadecimal. However, we need to send it in the transaction as a single bytes value, which Viem represents by a large hexadecimal string. Here we change the format by concatenating all the values, removing all the 0x's, and then adding one at the end.
1 await execPromise(`rm -r ${fname} ${fileID}`)23 return proof4}Cleanup and return the proof.
1const processMessage = async (message, signature) => {2 .3 .4 .56 const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))The public fields needs to be an array of 32-byte values. However, since we needed to divide the transaction hash between two Field values, it appears as a 16-byte value. Here we add zeros so Viem will understand it is actually 32 bytes.
1 const proof = await generateProof(noirResult.witness, `${fromAddress}-${nonce}`)Each address only uses each nonce once, so we can use a combination of fromAddress and nonce as a unique identifier for the witness file and the output directory.
1 try { 2 await zkBank.write.processTransaction([3 proof, publicFields])4 } catch (err) {5 console.log(`Verification error: ${err}`)6 throw Error("Can't verify the transaction onchain")7 }8 .9 .10 .11}అన్నీ చూపించుSend the transaction to the chain.
smart-contracts/src/ZkBank.sol
This is the onchain code that receives the transaction.
1// SPDX-License-Identifier: MIT23pragma solidity >=0.8.21;45import {HonkVerifier} from "./Verifier.sol";67contract ZkBank {8 HonkVerifier immutable myVerifier;9 bytes32 currentStateHash;1011 constructor(address _verifierAddress, bytes32 _initialStateHash) {12 currentStateHash = _initialStateHash;13 myVerifier = HonkVerifier(_verifierAddress);14 }అన్నీ చూపించుThe onchain code needs to keep track of two variables: the verifier (a separate contract that is created by nargo) and the current state hash.
1 event TransactionProcessed(2 bytes32 indexed transactionHash,3 bytes32 oldStateHash,4 bytes32 newStateHash5 );Every time the state it changed, we emit a TransactionProcessed event.
1 function processTransaction(2 bytes calldata _proof, 3 bytes32[] calldata _publicFields4 ) public {This function processes transactions. It gets the proof (as bytes) and the public inputs (as a bytes32 array), in the format that the verifier require (no minimize onchain processing and therefore gas costs).
1 require(_publicInputs[0] == currentStateHash, 2 "Wrong old state hash");The zero-knowledge proof needs to be that the transaction changes from our current hash to a new one.
1 myVerifier.verify(_proof, _publicFields);Call the verifier contract to verify the zero-knowledge proof. This step reverts the transaction if the zero-knowledge proof is wrong.
1 currentStateHash = _publicFields[1];23 emit TransactionProcessed(4 _publicFields[2]<<128 | _publicFields[3],5 _publicFields[0],6 _publicFields[1]7 );8 }9}అన్నీ చూపించుIf everything checks out, update the state hash to the new value and emit a TransactionProcessed event.
Abuses by the centralized component
Information security consists of three attributes:
- Confidentiality, users cannot read information they are not authorized to read.
- Integrity, information cannot be changed except by authorized users in an authorized manner.
- Availability, authorized users are able to use the system.
On this system integrity is provided through zero-knowledge proofs. Availability is much harder to guarantee, and confidentiality is impossible, because the bank has to know the balance for each account and all the transactions. There is no way to prevent an entity that has information from sharing that information.
It might be possible to create a true confidential bank using stealth addresses, but that is beyond the scope of this article.
False information
One way that the server can violate integrity is to provide false information when data is requested.
To solve this, we can write a second Noir program that receives the accounts as a private input, and the address for which information is requested as a public input. The output is the balance and nonce of that address, and the hash of the accounts.
Of course, this proof cannot be verified onchain, because we don't want to post nonces and balances onchain. However, it can be verified by the client code running in the browser.
Forced transactions
The usual mechanism to require availability and prevent censorship on L2s is forced transactions. But forced transactions are difficult to combine with zero-knowledge proofs. The server is the only entity that can verify transactions.
We can modify smart-contracts/src/ZkBank.sol to accept forced transactions, and not allow the server to change the state until the forced transactions are processed. However, this opens us up to a simple denial-of-service attack. What if a forced transaction is invalid and therefore impossible to process?
The solution is to have a zero-knowledge proof that a forced transaction is invalid. This gives the server three options:
- Process the forced transaction, providing a zero-knowledge proof that it has been processed and the new state hash.
- Reject the forced transaction, and provide a zero-knowledge proof to the contract that the transaction is invalid (unknown address, bad nonce, or insufficient balance).
- Ignore the forced transaction. There is no way to force the server to actually process the transaction, but it means the entire system in unavailable.
Availability bonds
In a real-life implementation there would probably be some kind of profit motive for keeping the server running. We can strengthen this incentive by having the server post an availability bond, which anybody can burn if a forced transaction is not processed within a certain amount of time.
Bad Noir code
Normally to get people to trust a smart contract we upload the source code to a block explorer. However, in the case of zero-knowledge proofs that is insufficient.
Verifier.sol contains the verification key, which is a function of the Noir program. However, that key does not tell us what the Noir program was. To actually have a trusted solution, you need to upload the Noir program (and the version that created it). Otherwise, the zero-knowledge proofs might reflect a different program, possibly one with a back door.
Until block explorers start allowing us to upload and verify Noir programs, you should do it yourself (preferably to IPFS). Then sophisticated users will be able to download the source code, compile to for themselves, create Verifier.sol, and see that it is identical to the one onchain.
Conclusion
Plasma type applications require a centralized component as information storage. This opens up possible vulnerabilities, but in return allows us to preserve privacy in ways that are not available on the blockchain itself. With zero-knowledge proofs we can ensure integrity, and possibly make it economically advantageous for whoever is running the centralized component to maintain availability.
Acknowledgements
- Josh Crites read a draft of this article and helped me with a thorny Noir issue.
Any remaining errors are my responsibility.