प्रमुख मजकुराकडे जा

Write an app-specific plasma that preserves privacy

zero-knowledge
server
offchain
privacy
Advanced
Ori Pomerantz
१५ ऑक्टोबर, २०२५
33 minute read

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 hashopens in a new tab 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 nonceopens in a new tab, used to prevent replay attacksopens in a new tab.

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.

  1. A web browser submits a signed transaction asking for a transfer from the signer's account to a different account.

  2. 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.
  3. The server calculates the new state by subtracting the transferred amount from the signer's balance and adding it to the recipient's balance.

  4. The server calculates a zero-knowledge proof that the state change is a valid one.

  5. 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
  6. The smart contract verifies the zero-knowledge proof.

  7. 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 Viteopens in a new tab, Reactopens in a new tab, Viemopens in a new tab and Wagmiopens in a new tab. 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 Nodeopens in a new tab. The zero-knowledge part is written in Noiropens in a new tab. We need version 1.0.0-beta.10, so after you install Noir as instructedopens in a new tab, run:

1noirup -v 1.0.0-beta.10

The blockchain we use is anvil, a local testing blockchain which is part of Foundryopens in a new tab.

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 hereopens in a new tab).

To see it in action:

  1. Make sure you have Nodeopens in a new tab and Noiropens in a new tab installed. Preferably, install them on a UNIX system such MacOS, Linux, or WSLopens in a new tab.

  2. 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-zk
    2cd 250911-zk-bank
    3cd client
    4npm install
    5npm run dev

    The 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.

  3. Open a browser with a wallet.

  4. 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.

  5. Browse to the client-side codeopens in a new tab.

  6. Connect to the wallet and select your destination account and amount.

  7. Click Sign and sign the transaction.

  8. Under the Prover.toml heading you'll find text. Replace server/noir/Prover.toml with that text.

  9. Execute the zero-knowledge proof.

    1cd ../server/noir
    2nargo execute

    The output should be similar to

    1ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute
    2
    3[zkBank] Circuit witness successfully solved
    4[zkBank] Witness saved to target/zkBank.gz
    5[zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85)
  10. 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 fileopens in a new tab 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 nonceopens in a new tab.

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_000
4nonce=0
5
6[[accounts]]
7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
8balance=100_000
9nonce=0
सर्व दाखवा

This is the way to specify an array of structures. For each entry, we specify the address, balance (in milliETH a.k.a. Finneyopens in a new tab), and next nonce value.

client/src/Transfer.tsx

This fileopens in a new tab 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 hooksopens in a new tab let us access the viemopens in a new tab 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 useStateopens in a new tab 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 messageopens in a new tab.

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 signature
4 })

Get the public keyopens in a new tab. This is required for the Noir ecrecoveropens in a new tab 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}"
2
3pubKeyX=${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 }
3
4 return (
5 <>
6 <h2>Transfer</h2>

This is the HTML (more accurately, JSXopens in a new tab) format of the component.

server/noir/src/main.nr

This fileopens in a new tab is the actual zero-knowledge code.

1use std::hash::pedersen_hash;

Pedersen hashopens in a new tab is provided with the Noir standard libraryopens in a new tab. This hash function is commonly used by zero-knowledge proofs. It is a lot easier to calculate inside aritmetic circuitsopens in a new tab compared to the standard hash functions.

1use keccak256::keccak256;
2use dep::ecrecover;

These two functions are external libraries, defined in Nargo.tomlopens in a new tab. They are exactly what they are named for, a function that calculates the keccak256 hashopens in a new tab and a function that verifies Ethereum signatures and recovers the signer's Ethereum address.

1global ACCOUNT_NUMBER : u32 = 5;

Noir is inspired by Rustopens in a new tab. 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 signaturesopens in a new tab 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. Fieldopens in a new tab is a number, typically up to 253 bits, that can be used directly in the arithmetic circuitopens in a new tab 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: u32
6}

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 flat
2}

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 }
6
7 flat
8}
9
10fn 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;
3
4 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");
2
3 account
4}

The assertopens in a new tab 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 stringopens in a new tab.

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);
3
4 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");
3
4 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;
2
3 newAccounts[from].balance -= txn.amount;
4 newAccounts[from].nonce += 1;
5 newAccounts[to].balance += txn.amount;
6
7 newAccounts
8}

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;
3
4 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-9
3 result += (messageBytes[i]-48).into();
4 }
5 if messageBytes[i] >= 65 & messageBytes[i] <= 70 { // A-F
6 result += (messageBytes[i]-65+10).into()
7 }
8 if messageBytes[i] >= 97 & messageBytes[i] <= 102 { // a-f
9 result += (messageBytes[i]-97+10).into()
10 }
11 }
12
13 result
14}
15
16fn 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-9
3 let digit = (messageBytes[i]-48);
4
5 if stillReadingAmount {
6 amount = amount*10 + digit.into();
7 }
8
9 if lookingForNonce { // We just found it
10 stillReadingNonce = true;
11 lookingForNonce = false;
12 }
13
14 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 }
27
28 (amount, nonce)
29}
सर्व दाखवा

Returning a tupleopens in a new tab 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();
5
6 txn.to = readAddress(messageBytes);
7 let (amount, nonce) = readAmountAndNonce(messageBytes);
8 txn.amount = amount;
9 txn.nonce = nonce;
10
11 txn
12}
सर्व दाखवा

This function actually turns the message into bytes and then turns the amounts into a TransferTxn.

1// The equivalent to Viem's hashMessage
2// https://viem.sh/docs/utilities/hashMessage#hashmessage
3fn 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 191opens in a new tab. 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 prefix
2 let prefix_bytes = [
3 0x19, // \x19
4 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 }
5
6 let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();
7
8 if MESSAGE_LENGTH <= 9 {
9 for i in 0..1 {
10 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
11 }
12
13 for i in 0..MESSAGE_LENGTH {
14 buffer[i+26+1] = messageBytes[i];
15 }
16 }
17
18 if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {
19 for i in 0..2 {
20 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
21 }
22
23 for i in 0..MESSAGE_LENGTH {
24 buffer[i+26+2] = messageBytes[i];
25 }
26 }
27
28 if MESSAGE_LENGTH >= 100 {
29 for i in 0..3 {
30 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
31 }
32
33 for i in 0..MESSAGE_LENGTH {
34 buffer[i+26+3] = messageBytes[i];
35 }
36 }
37
38 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 moduluopens in a new tab 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);
2
3 let mut (hash1, hash2) = (0,0);
4
5 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 ecrecoveropens in a new tab, with two important differences:

  • If the signature is not valid, the call fails an assert and 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 hash2
3 )
4}
5
6fn 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 array
14 Field, // Hash of new accounts array
15 Field, // First 16 bytes of message hash
16 Field, // Last 16 bytes of message hash
17 )
सर्व दाखवा

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);
6
7 txn.from = fromAddress;
8
9 let newAccounts = apply_transfer_txn(accounts, txn);
10
11 (
12 hash_accounts(accounts),
13 hash_accounts(newAccounts),
14 txnHash1,
15 txnHash2
16 )
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:

  1. Stop Vite if it is running.

  2. Download the branch with the server and ensure you have all the necessary modules.

    1git checkout 02-add-server
    2cd client
    3npm install
    4cd ../server
    5npm install

    The is no need to compile the Noir code, it is the same as the code you used for stage 1.

  3. Start the server.

    1npm run start
  4. In a separate command-line window, run Vite to serve the browser code.

    1cd client
    2npm run dev
  5. Browse to the client code at http://localhost:5173opens in a new tab

  6. 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 attackopens in a new tab), 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.

  7. 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.

  8. 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 start
    2
    3> server@1.0.0 start
    4> node --experimental-json-modules index.mjs
    5
    6Listening on port 3000
    7Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed
    8New 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 processed
    15New 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 processed
    22New 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 fileopens in a new tab contains the server process, and interacts with the Noir code at main.nropens in a new tab. Here is an explanation of the interesting parts.

1import { Noir } from '@noir-lang/noir_js'

The noir.jsopens in a new tab 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 request
2const accountInformation = async signature => {
3 const fromAddress = await recoverAddress({
4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
5 signature
6 })

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 key
2 const pubKey = await recoverPublicKey({
3 hash,
4 signature
5 })

Now that we run JavaScript code on the server, we can get the public key there, rather than on the client.

1 let noirResult
2 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: Accounts
9 })
सर्व दाखवा

noir.execute runs the Noir program. The parameters are equivalent to those provided in Prover.tomlopens in a new tab. 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 -= amount
3 Accounts[toAccountNumber].balance += amount

Apply 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

  1. Stop the server and client processes.

  2. Download the branch with the smart contracts and ensure you have all the necessary modules.

    1git checkout 03-smart-contracts
    2cd client
    3npm install
    4cd ../server
    5npm install
  3. Run anvil in a separate command-line window.

  4. Generate the verification key and the solidity verifier, then copy the verifier code to the Solidity project.

    1cd noir
    2bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak
    3bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol
    4cp target/Verifier.sol ../../smart-contracts/src
  5. Go to the smart contracts and set the environment variables to use the anvil blockchain.

    1cd ../../smart-contracts
    2export ETH_RPC_URL=http://localhost:8545
    3ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
  6. Deploy Verifier.sol and 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
  7. Deploy the ZkBank contract.

    1ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`
    2echo $ZKBANK_ADDRESS

    The 0x199..67b value is the Pederson hash of the initial state of Accounts. If you modify this initial state in server/index.mjs, you can run a transaction to see the initial hash reported by the zero-knowledge proof.

  8. Run the server.

    1cd ../server
    2npm run start
  9. Run the client in a different command-line window.

    1cd client
    2npm run dev
  10. Run some transactions.

  11. To verify that the state changed onchain, restart the server process. See that ZkBank no 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 start
    2
    3> server@1.0.0 start
    4> node --experimental-json-modules index.mjs
    5
    6Listening on port 3000
    7Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason:
    8Wrong old state hash
    9
    10Contract Call:
    11 address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
    12 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'
3
4const execPromise = util.promisify(exec)

We need to use the Barretenberg packageopens in a new tab 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.jsopens in a new tab. The JavaScript library is much slower than running code natively, so we use execopens in a new tab 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}`)
2
3 return proof
4}

Cleanup and return the proof.

1const processMessage = async (message, signature) => {
2 .
3 .
4 .
5
6 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: MIT
2
3pragma solidity >=0.8.21;
4
5import {HonkVerifier} from "./Verifier.sol";
6
7contract ZkBank {
8 HonkVerifier immutable myVerifier;
9 bytes32 currentStateHash;
10
11 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 newStateHash
5 );

Every time the state it changed, we emit a TransactionProcessed event.

1 function processTransaction(
2 bytes calldata _proof,
3 bytes32[] calldata _publicFields
4 ) 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];
2
3 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 addressesopens in a new tab, 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 requestedopens in a new tab.

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 transactionsopens in a new tab. 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 exploreropens in a new tab. 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 IPFSopens in a new tab). 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.

See here for more of my workopens in a new tab.

Acknowledgements

  • Josh Crites read a draft of this article and helped me with a thorny Noir issue.

Any remaining errors are my responsibility.

Page last update: १८ ऑक्टोबर, २०२५

हे मार्गदर्शन उपयुक्त होते का?