I wrote this blog for the Penumbra Labs site. You can see the original post here. Below is an abbreviated version.
Shielded blockchains like Penumbra provide privacy through the use of zeroknowledge proofs (ZKPs): actions that change the public chain state can be verified without providing the underlying private data.
Transparent “Proofs”
Our plan for implementing Penumbra has been to use an approach which allows quick iterations on the design of the system without spending significant effort each iteration to update zeroknowledge circuits. To ensure that the design of the system was kept compatible with zeroknowledge proofs, each action had a “transparent proof”, for example for spending notes:


This SpendProof
struct trivially stored the witnesses in cleartext. The prover created this struct, and sent it to the node, where a verification method was called using the public inputs provided in the transaction. The verification method did all the integrity checks the real proof would: verifying the Merkle path, checking the prover had an opening of the public commitment, and so on. This did not provide privacy, but it let us rapidly prototype the system while refining the protocol design, with assurance that when our requirements became stable, we could fill in the proofs.
ZeroKnowledge
As we approach mainnet and the system functionality becomes stable, we began migrating from transparent proofs to zeroknowledge proofs starting with testnet 46, codenamed Lysithea, released on February 27th, 2023. Now that Penumbra’s multiasset shielded pool is stable, that release migrated outputs (actions that create new notes) and spends (actions that consume existing notes) to use zeroknowledge proofs. Interaction with Penumbra’s DEX, governance, and staking systems will follow.
One of Penumbra’s design goals is to create a usable privacy system. That means fast proving times: at mainnet we’re aiming for proving times below one second on enduser devices. We can do this by performing the proving for all actions concurrently and by using Groth16. For Penumbra’s initial ZKPs, we use the pairingfriendly BLS12377 proving curve and the Arkworks implementation of the Groth16 proving system. It has excellent outofthebox performance even before optimization: on an M1 macbook, transactions with three actions (one spend, two outputs) typically take under 1.3s to generate. We also get the benefits of very small proof size and using a mature system that has been in production for years.
A disadvantage of Groth16 is that it requires a circuitspecific setup, meaning each time we change our proof statements, we need to rerun a decentralized setup procedure to generate new parameters for the prover and verifier. The requirement for this process is there is at least one honest participant in the setup, thus motivating a large setup process involving many participants. Stay tuned for more details on the setup procedure and how you can participate!
Our Spend proof from above now looks like this:


Our ZK proofs are now just three group elements in size. The prover uses provided proving parameters (type ProvingKey<Bls12_377>
), which we distribute via a penumbraproofparams
crate, to create the proof using their private witnesses and public inputs. The verifier uses the corresponding verifying key (type PreparedVerifyingKey<Bls12_377>
) in order to verify the proofs on the node using the public inputs provided in the transaction.
Circuit Programming
To generate the circuit for each action, we first need to represent the statements we want to prove  for example that the prover knows an opening of a specific public commitment  in a way that our proving system can understand. For Groth16 proofs, this means representing all statements to be proved incircuit as a rank1 constraint system (R1CS). We need to be able to write down elliptic curve operations, hash function evaluations and so on, as a number of constraint equations that are simple linear combinations of field element variables.
Several of our dependencies now have this R1CS functionality: decaf377
, poseidon377
, and penumbratct
all have an optional r1cs
feature, while penumbracrypto
has R1CS functionality inline next to each type that needs to be represented incircuit. This lets us do elliptic curve operations, SNARKfriendly hashing, and all other operations incircuit. For example, here is the type that represents incircuit which path a node in our tiered commitment tree can take:


We can see incircuit the path of the node at a given height is represented by four boolean constraints. The Fq
type here just represents the type of the field elements used by the proving system.
These R1CS types are used during constraint synthesis. We write Rust code to define types that define bundles of constraints. We then use those types along with the Arkworks ConstraintSystem<Fq>
, which internally keeps track of all the R1CS constraints we build up by:
 allocating witness or input variables,
 defining constants, or
 performing an operation on defined variables or constants.
An upstream Arkworks trait called ConstraintSynthesizer
is implemented for each of our circuit/actions. Here’s part of the implementation for our Spend circuit:


In our abridged and slightly simplified constraint synthesis example here, we can see that we first witness a NoteVar
, providing a reference to the underlying constraint system. This allocates a variable incircuit, adding constraints as we go.
Next, we define a public balance commitment, which represents a commitment to the value balance of this action. The public balance commitment we call claimed_balance_commitment_var
as it represents the public value of the balance commitment: the verifier needs to certify that the balance commitment was computed correctly, using private variables it does not have access to on the NoteVar
. The prover adds constraints to demonstrate that by calling commit
on the value of the NoteVar
, and adding constraints that the output of the commit
method must be equal to the corresponding public input.
In a similar fashion, we can build up all constraints in an ergonomic manner by writing regular Rust code.