Building your first ZKP program on Solana
Sufferer
Posted on January 8, 2024
👋 Introduction
Zero-knowledge proofs are a kind of security tool in cryptography. They let one person (the prover) show another person (the verifier) that something is true, without giving away any other details. Think of it like proving you know a secret without actually telling the secret. This tool is really useful for keeping things private and has been used in things like making payments without revealing identities, sending messages anonymously, and in secure systems where trust is important (bridge).
SOLANA UPGRADE 2023.10
Prior to Solana v1.16 it wasn't possible to verify cryptographic proofs on Solana efficiently. Due to the prevalence of complex computation behind the pairing algorithm, it was necessary to increase the functionality of Solana by adding syscalls to conduct proof verification. This functionality was added in the Solana v1.16 update and at the time of this writing is only available on testnet.
🦄 This tutorial will cover
- The basics of zero-knowledge cryptography and specifically zk-SNARKs (Zero-Knowledge Succinct Non-Interactive Argument of Knowledge)
- Initiating a trusted setup ceremony (using the Powers of Tau)
- Writing and compiling a simple ZK circuit (using the Circom language)
- Generating, deploying, and testing a Solana contract to verify a sample ZK-proof
🟥🟦 Explaining ZK-proofs with a color-focused example
Let's break down zero-knowledge proofs with an easy-to-understand scenario. Imagine you need to convince someone who is color-blind that different colors can be distinguished. We'll tackle this challenge interactively. Picture this: the color-blind person (acting as the verifier) selects two sheets of paper, one red 🟥 and one blue 🟦, which look identical to them.
The verifier then shows you (the prover) one of these papers and asks you to remember its color. Next, they hide the paper behind their back, possibly switching it with the other one, and ask you to identify if the color is the same or has changed. If you consistently identify the color correctly, it suggests you can differentiate colors (or you're just lucky, as there's a 50% chance of guessing right).
If this test is repeated 10 times and you're always correct, the verifier becomes about 99.90234% sure (calculated as 1 - (1/2)^10) that you're genuinely distinguishing the colors. After 30 repetitions, their confidence level rises to 99.99999990686774% (1 - (1/2)^30).
However, this method, while interactive, isn't practical for a decentralized application (DApp) that requires users to confirm data without multiple transactions. That's why non-interactive methods like Zk-SNARKs and Zk-STARKs are important.
In this guide, we'll focus on Zk-SNARKs. For those interested in Zk-STARKs, you can learn more on the Starkware. Additionally, a comparison between Zk-SNARKs and Zk-STARKs is available on the Panther Protocol blog.
🎯 Zk-SNARK: Zero-Knowledge Succinct Non-Interactive Argument of Knowledge
A Zk-SNARK is a non-interactive proof system where the prover can demonstrate to the verifier that a statement is true by simply submitting one proof. And the verifier is able to verify the proof in a very short time. Typically, dealing with a Zk-SNARK consists of three main phases:
- Conducting a trusted setup using a multi-party computation (MPC) protocol to generate proving and verification keys (using Powers of TAU)
- Generating a proof using a prover key, public input, and secret input (witness)
- Verifying the proof
Let's set up our development environment and start coding!
⚙ Development environment setup
Let's begin the process by taking the following steps:
Create a new project called "simple-zk" using create-solana-dapp, after that, enter a name for your contract (e.g. simple-zk).
npx create-solana-dapp@latest simple-zk
cd simple-zk
Next we’ll clone the snarkjs repo inside simple-zk folder
git clone https://github.com/iden3/snarkjs
cd snarkjs
npm ci
cd ../simple-zk
Then we’ll install the required libraries needed for ZkSNARKs
npm add --save-dev snarkjs ffjavascript
npm i -g circom
Finally, we'll install the latest verifier on Solana implemented by Light protocol:
git clone https://github.com/Lightprotocol/groth16-solana
cd groth16-solana
npm i
cd ../simple-zk
Great! Now we are ready to start writing our first ZK project on Solana!
We currently have a main folder and two sub folders that make up our ZK project:
simple-zk folder: contains our native Solana template which will enable us to write our circuit and contracts and tests
- snarkjs folder: contains the snarkjs repo that we cloned in step 2
- groth16-solana folder: contains the verifying key export function we cloned in step 2
Circom circuit
First let's create a folder simple-zk/circuits and then create a file in it and add the following code to it:
template Multiplier() {
signal private input a;
signal private input b;
//private input means that this input is not public and will not be revealed in the proof
signal output c;
c <== a*b;
}
component main = Multiplier();
Above we added a simple multiplier circuit. By using this circuit we can prove that we know two numbers that when multiplied together result in a specific number (c) without revealing the corresponding numbers (a and b) themselves.
To read more about the circom language consider having a look at this website.
Next we’ll create a folder for our build files and move the data there by conducting the following (while being in the simple-zk folder):
mkdir -p ./build/circuits
cd ./build/circuits
💪 Creating a trusted setup with Powers of TAU
Now it's time to build a trusted setup. To carry out this process, we’ll make use of the Powers of Tau method (which probably takes a few minutes to complete). Let’s get into it:
echo 'prepare phase1'
node ../../../snarkjs/build/cli.cjs powersoftau new bn128 12 pot12_0000.ptau -v
echo 'contribute phase1 first'
node ../../../snarkjs/build/cli.cjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="First contribution" -v -e="random text"
echo 'apply a random beacon'
node ../../../snarkjs/build/cli.cjs powersoftau beacon pot12_0001.ptau pot12_beacon.ptau 0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f 10 -n="Final Beacon"
echo 'prepare phase2'
node ../../../snarkjs/build/cli.cjs powersoftau prepare phase2 pot12_beacon.ptau pot12_final.ptau -v
echo 'Verify the final ptau'
node ../../../snarkjs/build/cli.cjs powersoftau verify pot12_final.ptau
After the process above is completed, it will create the pot12_final.ptau file in the build/circuits folder, which can be used for writing future related circuits.
CONSTRAINT SIZE
If a more complex circuit is written with more constraints, it is necessary to generate your PTAU setup using a larger parameter.
You can remove the unnecessary files:
rm pot12_0000.ptau pot12_0001.ptau pot12_0002.ptau pot12_beacon.ptau
📜 Circuit compilation
Now let's compile the circuit by running the following command from the build/circuits folder:
circom ../../circuits/Multiplier.circom --r1cs --wasm --sym
Now we have our circuit compiled to the build/circuits/Multiplier.sym, build/circuits/Multiplier.r1cs, and build/circuits/Multiplier.wasm files.
ALTBN-128 AND BLS12-381 CURVES
The altbn-128 and bls12-381 elliptic curves are currently supported by snarkjs. The altbn-128 curve is only supported on Ethereum. Furthermore, on Solana only the bn128 curve is supported.
Let's check the constraint size of our circuit by entering the following command:
node ../../../snarkjs/build/cli.cjs r1cs info Multiplier.r1cs
Therefore, the correct result should be:
[INFO] snarkJS: Curve: bn128
[INFO] snarkJS: # of Wires: 4
[INFO] snarkJS: # of Constraints: 1
[INFO] snarkJS: # of Private Inputs: 2
[INFO] snarkJS: # of Public Inputs: 0
[INFO] snarkJS: # of Labels: 4
[INFO] snarkJS: # of Outputs: 1
Now we can generate the reference zkey by executing the following:
node ../../../snarkjs/build/cli.cjs zkey new Multiplier.r1cs pot12_final.ptau Multiplier_0000.zkey
Then we’ll add the below contribution to the zkey:
echo "some random text" | node ../../../snarkjs/build/cli.cjs zkey contribute Multiplier_0000.zkey Multiplier_final.zkey --name="1st Contributor" -v -e="more random text"
Next, let's export the final zkey:
node ../../../snarkjs/build/cli.cjs zkey export verificationkey Multiplier_final.zkey verification_key.json
Then we’ll remove the unnecessary files:
rm Multiplier_0000.zkey
After conducting the above processes, the build/circuits folder should be displayed as follows:
build
└── circuits
├── Multiplier_final.zkey
├── Multiplier.r1cs
├── Multiplier.sym
├── Multiplier.wasm
├── pot12_final.ptau
└── verification_key.json
✅ Exporting the verifier contract
The final step in this section is to generate the Solana verifier contract which we’ll use in our ZK project.
cd ../../../groth16-solana
npm run parse-vk ../circuits/build/circuits/verification_key.json
Then the verifying_key.rs file is generated in the groth16-solana folder.
🚢 Verifier contract deployment
Let's review the groth16-solana/verifying_key.rs file step-by-step because it contains the magic of ZK-SNARKs:
use groth16_solana::groth16::Groth16Verifyingkey;
pub const VERIFYINGKEY: Groth16Verifyingkey = Groth16Verifyingkey {
nr_pubinputs: 2,
vk_alpha_g1: [
46,198,28,80,85,219,64,95,16,86,37,55,105,174,107,82,67,212,66,53,189,244,65,129,153,249,14,192,208,23,189,255,
46,213,140,248,251,76,224,235,78,79,47,37,11,253,131,73,220,6,86,57,31,37,150,116,245,62,83,76,46,234,4,132,
],
vk_beta_g2: [
40,239,114,219,169,186,198,208,56,242,155,131,18,151,60,17,8,209,95,232,155,207,165,191,9,240,203,222,208,254,251,118,
23,244,194,167,148,204,162,27,134,119,235,184,191,212,15,40,60,80,108,153,207,223,171,38,222,36,166,12,84,109,176,64,
14,48,121,113,255,10,248,201,22,70,211,61,239,180,243,240,193,117,248,132,92,108,103,68,96,104,143,249,30,26,84,65,
6,82,118,185,31,130,171,49,7,175,0,68,128,209,81,253,68,111,106,183,12,127,60,70,105,211,9,21,170,72,70,58,
],
vk_gamme_g2: [
25,142,147,147,146,13,72,58,114,96,191,183,49,251,93,37,241,170,73,51,53,169,231,18,151,228,133,183,174,243,18,194,
24,0,222,239,18,31,30,118,66,106,0,102,94,92,68,121,103,67,34,212,247,94,218,221,70,222,189,92,217,146,246,237,
9,6,137,208,88,95,240,117,236,158,153,173,105,12,51,149,188,75,49,51,112,179,142,243,85,172,218,220,209,34,151,91,
18,200,94,165,219,140,109,235,74,171,113,128,141,203,64,143,227,209,231,105,12,67,211,123,76,230,204,1,102,250,125,170,
],
vk_delta_g2: [
24,192,59,38,123,253,143,209,31,2,6,232,161,211,127,130,243,195,167,30,70,1,188,224,50,84,152,107,192,21,180,237,
28,114,45,187,38,122,20,254,202,71,245,235,178,126,211,179,176,61,10,103,34,65,197,118,5,27,150,189,46,60,94,185,
35,207,37,209,197,84,87,106,62,27,41,116,198,235,14,90,222,127,26,30,171,63,255,141,41,53,206,215,237,66,117,12,
27,178,142,201,231,67,82,137,245,78,19,88,40,84,123,79,2,191,80,218,78,125,94,231,178,101,121,12,31,4,17,239,
],
vk_ic: &[
[
26,117,81,38,33,231,103,28,33,207,192,9,40,163,239,213,143,75,215,83,82,99,239,43,187,29,215,94,171,232,109,97,
1,34,199,15,152,146,18,109,191,206,154,14,65,233,113,21,117,171,39,197,154,75,176,199,151,75,117,63,170,66,13,98,
],
[
26,34,168,154,124,160,104,241,73,142,20,231,181,8,8,182,0,225,51,233,173,217,93,237,166,202,87,151,55,51,87,197,
43,145,207,212,5,45,208,198,22,238,212,232,126,17,37,125,180,176,67,75,207,58,102,122,182,244,89,209,133,253,4,255,
],
]
};
The below lines are the new altbn (altbn-128) that allows pairing checks to be conducted on the Solana Blockchain.
pub fn prepare_inputs(&mut self) -> Result<(), Groth16Error> {
let mut prepared_public_inputs = self.verifyingkey.vk_ic[0];
for (i, input) in self.public_inputs.iter().enumerate() {
let mul_res = alt_bn128_multiplication(
&[&self.verifyingkey.vk_ic[i + 1][..], &input[..]].concat(),
)
.map_err(|_| Groth16Error::PreparingInputsG1MulFailed)?;
prepared_public_inputs =
alt_bn128_addition(&[&mul_res[..], &prepared_public_inputs[..]].concat())
.map_err(|_| Groth16Error::PreparingInputsG1AdditionFailed)?[..]
.try_into()
.map_err(|_| Groth16Error::PreparingInputsG1AdditionFailed)?;
}
self.prepared_public_inputs = prepared_public_inputs;
Ok(())
}
Next there are several simple util functions that are used to load the proof data sent to the contract:
And the last part is the groth16Verify function which is required to check the validity of the proof sent to the contract.
pub fn verify(&mut self) -> Result<bool, Groth16Error> {
self.prepare_inputs()?;
let pairing_input = [
self.proof_a.as_slice(),
self.proof_b.as_slice(),
self.prepared_public_inputs.as_slice(),
self.verifyingkey.vk_gamme_g2.as_slice(),
self.proof_c.as_slice(),
self.verifyingkey.vk_delta_g2.as_slice(),
self.verifyingkey.vk_alpha_g1.as_slice(),
self.verifyingkey.vk_beta_g2.as_slice(),
]
.concat();
let pairing_res = alt_bn128_pairing(pairing_input.as_slice())
.map_err(|_| Groth16Error::ProofVerificationFailed)?;
if pairing_res[31] != 1 {
return Err(Groth16Error::ProofVerificationFailed);
}
Ok(true)
}
🧑💻 Writing tests for the verifier
First, we’ll need to create a test file and import several packages that we will use in the test:
import * as snarkjs from "snarkjs";
import path from "path";
import {buildBn12381, utils} from "ffjavascript";
const {unstringifyBigInts} = utils;
If you run the test, the result will be a TypeScript error, because we don't have a declaration file for the module 'snarkjs' & ffjavascript
. This can be addressed by editing the tsconfig.json file in the root of the simple-zk folder. We'll need to change the strict option to false in that file
We'll also need to import the circuit.wasm and circuit_final.zkey files which will be used to generate the proof to send to the contract.
import * as web3 from "@solana/web3.js";
import * as snarkjs from "snarkjs";
import path from "path";
import { buildBn128, utils } from "ffjavascript";
import { expect } from "chai";
const { unstringifyBigInts } = utils;
const wasmPath = path.join(__dirname, "../../../circuits", "Multiplier.wasm");
const zkeyPath = path.join(__dirname, "../../../circuits", "Multiplier_final.zkey");
const PROGRAM_ID = new web3.PublicKey("8bg6eH9sKpjNNMzWxnqKUa94rTSHTeWRNXrHy5HeP6an");
const ACCOUNT_TO_QUERY = new web3.PublicKey("511wHA3Q7KV4rNL2WN3LY1t9DCNn5nYVujTU3RgQuHZS");
const connection = new web3.Connection(web3.clusterApiUrl('devnet'), "confirmed");
const wallet = web3.Keypair.generate();
describe('Groth16 Verifier', () => {
it('should verify a valid proof', async () => {
// Request an airdrop to fund the wallet
const airdropSignature = await connection.requestAirdrop(
wallet.publicKey,
web3.LAMPORTS_PER_SOL * 1 // Request 1 SOL
);
await connection.confirmTransaction(airdropSignature);
// Generate proof using snarkjs
let input = { "a": 3, "b": 4 };
let { proof, publicSignals } = await snarkjs.groth16.fullProve(input, wasmPath, zkeyPath);
console.log(publicSignals)
console.log(proof)
let curve = await buildBn128();
let proofProc = unstringifyBigInts(proof);
publicSignals = unstringifyBigInts(publicSignals);
let pi_a = g1Uncompressed(curve, proofProc.pi_a);
let pi_a_0_u8_array = Array.from(pi_a);
console.log(pi_a_0_u8_array);
// pi_a = reverseEndianness(pi_a)
// pi_a = negateG1(curve, pi_a); // Negate pi_a
// pi_a = reverseEndianness(pi_a); // Reverse endianness of negated pi_a
const pi_b = g2Uncompressed(curve, proofProc.pi_b);
let pi_b_0_u8_array = Array.from(pi_b);
console.log(pi_b_0_u8_array.slice(0, 64));
console.log(pi_b_0_u8_array.slice(64, 128));
const pi_c = g1Uncompressed(curve, proofProc.pi_c);
let pi_c_0_u8_array = Array.from(pi_c);
console.log(pi_c_0_u8_array);
// Assuming publicSignals has only one element
const publicSignalsBuffer = to32ByteBuffer(BigInt(publicSignals[0]));
let public_signal_0_u8_array = Array.from(publicSignalsBuffer);
console.log(public_signal_0_u8_array);
/* Only uncomment this when you have deployed the solana program on version 1.17
const transaction = new web3.Transaction();
transaction.add(web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }));
transaction.add(web3.ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 2 }));
// Define the accounts involved in the transaction
const accounts = [
{
pubkey: wallet.publicKey, // The public key of the account sending the proof
isSigner: true,
isWritable: true
},
{
pubkey: ACCOUNT_TO_QUERY,
isSigner: false,
isWritable: true
},
];
const serializedData = Buffer.concat([
pi_a,
pi_b,
pi_c,
publicSignalsBuffer
]);
// Create the instruction
const instruction = new web3.TransactionInstruction({
keys: accounts,
programId: PROGRAM_ID,
data: serializedData // The data containing the proof and public inputs
});
// Add the instruction to the transaction
transaction.add(instruction);
// Sign and send the transaction
const signature = await web3.sendAndConfirmTransaction(
connection,
transaction,
[wallet], // Array of signers, in this case, just the wallet
{
skipPreflight: true
}
);
console.log("Transaction signature", signature);
// Send and confirm transaction
await web3.sendAndConfirmTransaction(
connection,
transaction,
[wallet]
);
// Fetch and assert the result
// const ctxRes = await getRes(PROGRAM_ID);
// expect(ctxRes).not.to.equal(0); // Replace with your expected result
});
*/
});
To carry out the next step it is necessary to define the g1Compressed, g2Compressed, and to32ByteByffer functions. They will be used to convert the cryptographic proof to the format that the contract expects.
function g1Uncompressed(curve, p1Raw) {
let p1 = curve.G1.fromObject(p1Raw);
let buff = new Uint8Array(64); // 64 bytes for G1 uncompressed
curve.G1.toRprUncompressed(buff, 0, p1);
return Buffer.from(buff);
}
function g2Uncompressed(curve, p2Raw) {
let p2 = curve.G2.fromObject(p2Raw);
let buff = new Uint8Array(128); // 128 bytes for G2 uncompressed
curve.G2.toRprUncompressed(buff, 0, p2);
return Buffer.from(buff);
}
function to32ByteBuffer(bigInt) {
const hexString = bigInt.toString(16).padStart(64, '0');
const buffer = Buffer.from(hexString, "hex");
return buffer;
}
Now we can send the cryptographic proof to the contract.
But for the incompatible version of Solana right now (released in v1.17), we will write a test in our rust program using our generated proof data.
Find the logs of proof buffer data
// other codes...
console.log(pi_a_0_u8_array)
// other codes...
console.log(pi_b_0_u8_array.slice(0, 64));
console.log(pi_b_0_u8_array.slice(64, 128));
// other codes ...
console.log(pi_c_0_u8_array)
// other codes ...
console.log(publicSignal_0_u8_array)
Remember these data, you will need to use them in the rust program soon.
Now let's take a look at our groth16.rs. Navigate to #config(tests). Then add our own test variables:
// other codes...
pub const VERIFYING_KEY_2: Groth16Verifyingkey; // using your verifying key here
// other codes ...
pub const PROOF_2: [u8; 256]; //... concat pi_a_0_u8_array, pi_b_0_u8_array.slice(0, 64), pi_b_0_u8_array.slice(64, 128), pi_c_0_u8_array in your console.log before
// other codes ...
pub const PUBLIC_INPUTS_2: [[u8; 32]; 1]; // use publicSignal_0_u8_array in your console.log step before
Finally, write custom test function with our own data
#[test]
fn proof_2_verification_should_succeed() {
let proof_a: G1 = G1::deserialize_with_mode(
&*[&change_endianness(&PROOF_2[0..64]), &[0u8][..]].concat(),
Compress::No,
Validate::Yes,
)
.unwrap();
let mut proof_a_neg = [0u8; 65];
proof_a
.neg()
.x
.serialize_with_mode(&mut proof_a_neg[..32], Compress::No)
.unwrap();
proof_a
.neg()
.y
.serialize_with_mode(&mut proof_a_neg[32..], Compress::No)
.unwrap();
let proof_a = change_endianness(&proof_a_neg[..64]).try_into().unwrap();
let proof_b = PROOF_2[64..192].try_into().unwrap();
let proof_c = PROOF_2[192..256].try_into().unwrap();
let mut verifier =
Groth16Verifier::new(&proof_a, &proof_b, &proof_c, &PUBLIC_INPUTS_2, &VERIFYING_KEY_2)
.unwrap();
verifier.verify().unwrap();
}
Are you ready to verify your first proof on Solana blockchain? To start off this process, let's run the rust test by inputting the following:
cargo test
The result should be as follows:
Finished test [unoptimized + debuginfo] target(s) in 0.87s
Running unittests src/lib.rs (target/debug/deps/solanazk-fcce2a686ac6f227)
running 4 tests
test groth16::tests::proof_2_verification_should_succeed ... ok
test groth16::tests::proof_verification_should_succeed ... ok
test groth16::tests::wrong_proof_verification_should_not_succeed ... ok
test groth16::tests::proof_verification_with_compressed_inputs_should_succeed ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s
Doc-tests solanazk
running 1 test
test src/groth16.rs - groth16 (line 1) ... ignored
test result: ok. 0 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
Now you are ready to deploy your program to devnet/testnet when Solana v1.17 comes out (with verification taking less than 200_000 compute units). The Typescript test will fail for now. But when it comes, just replace the PROGRAM_ID
with your newly deployed one then you will be alright.
In order to check the repo that contains the code from this tutorial, click on this link.
🏁 Conclusion
In this tutorial you learned the following skills:
- The intricacies of zero-knowledge and specifically ZK-SNARKs
- Writing and compiling Circom circuiting ( Increased familiarity with MPC and the Powers of TAU, which were used to generate verification keys for a circuit )
- Became familiar with a Snarkjs library to export a Solana verifier for a circuit
- Became familiar with native Solana for verifier deployment and test writing
Note: The above examples taught us how to build a simple ZK use case. That said, there are a wide range of highly complex ZK-focused use cases that can be implemented in a wide range of industries. Some of these include:
private voting systems 🗳
private lottery systems 🎰
private auction systems 🤝
private identity/oracle 👤
private poker game ♠️
private transaction layer 💸 (Elusiv)
private machine learning 🤖
private Zk-Rollups 🗞️
private stablecoin 🏦
private ZK light client for trustless bridging 🌉
If you have any questions or have noticed an error - feel free to write to the author - @zeref101
📌 References
Solana v1.16 Upgrade
groth16-solana
SnarkJS
create-solana-dapp CLI
Circom
Tutorial Repo
ZK Comparision
zkSTARK
📖 See Also
Solana ZK Tour (Dec 2023)
State of Privacy on Solana (July 2023)
Confidential Token on Solana
Solana ZK Light Client for a Trustless Bridge
Succinct's vision on ZK
Private Solana Program 🎭 (Light Protocol)
Another Circom Verifier
Otter Cash: Riptide Hackathon Winner
Posted on January 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.