Private transactions
In this implementation, we'll achieve privacy with FHE. Any private values (balances, transaction amounts) will be encrypted as ciphertexts, and transparent computation will rely on homomorphic FHE programs.1 We'll prove the correctness and validity of these private values with linked ZKPs.
Let's get to it!
Program walkthrough
The complete example lives on GitHub if you want to see it altogether.
Setup
First, let's import everything we need:
use std::collections::HashMap;
use sunscreen::{
bulletproofs::BulletproofsBackend,
fhe_program,
linked::LinkedProof,
types::{
bfv::Signed,
zkp::{
AsFieldElement, BfvSigned, BulletproofsField, ConstrainCmp, ConstrainFresh, Field,
FieldSpec,
},
Cipher,
},
zkp_program, zkp_var, Ciphertext, CompiledFheProgram, CompiledZkpProgram, Compiler,
FheProgramInput, FheZkpApplication, FheZkpRuntime, Params, PrivateKey, PublicKey, Result,
ZkpProgramInput,
};
FHE programs
The FHE programs are mostly trivial. We definitely need addition and subtraction
to update user's balances, and these are performed on encrypted values. In
addition, in our implementation, we'll assume that users are going to
deposit into their private accounts from a public account (perhaps the native
currency of a blockchain, like ETH), so we'll make use of the fact that we can
perform addition on mixed ciphertext and plaintext values.
/// Subtract the transaction amount from the sender's balance.
#[fhe_program(scheme = "bfv")]
fn transfer_from(balance: Cipher<Signed>, tx: Cipher<Signed>) -> Cipher<Signed> {
balance - tx
}
/// Add the transaction amount to the receiver's balance.
#[fhe_program(scheme = "bfv")]
fn transfer_to(balance: Cipher<Signed>, tx: Cipher<Signed>) -> Cipher<Signed> {
balance + tx
}
/// Add the public transaction amount to a user's balance.
#[fhe_program(scheme = "bfv")]
fn deposit_to(balance: Cipher<Signed>, deposit: Signed) -> Cipher<Signed> {
balance + deposit
}
ZKP programs
Transfer
Let's first consider what constitutes a valid transfer. Since we need to add the transaction amount to both the sender and the receiver's balance, we actually need two ciphertexts, one encrypted under the sender's key and the other under the receiver's key. We'll need to prove that
- the sender has enough funds to send the tx amount
- the tx amount is positive
- the ciphertexts encrypt the same amount
- the ciphertexts are fresh encryptions
The first three are rather obvious requirements for the correctness of the payment system, but the last one is more subtle. We need to ensure these are fresh encryptions because BFV doesn't have unbounded computation depth. We wouldn't want a bad actor to be able to send an encrypted transaction with a ton of noise that causes the receiver's balance to be un-decryptable.
As we'll see below, the last two properties are handled outside of the ZKP program (by the SDLP), so let's validate the first two properties.
/// Validate a transfer transaction.
#[zkp_program]
fn validate_transfer<F: FieldSpec>(
#[linked] tx: BfvSigned<F>,
#[linked] sender_balance: BfvSigned<F>,
) {
let tx = tx.into_field_elem();
let sender_balance = sender_balance.into_field_elem();
// Transaction amount must be greater than 0.
tx.constrain_gt_bounded(zkp_var!(0), 64);
// Transaction amount cannot exceed sender's balance.
tx.constrain_le_bounded(sender_balance, 64);
}
Registration
As we noted above, we're assuming a deposit to a private account occurs from a public account. But the user's balance must be encrypted, so how can we initialize it? We can't easily encrypt the initial deposit, at least in a consensus-driven setting, as encryptions are randomized. Instead, we'll have the user send over their encrypted initial balance with a ZKP proving that the encrypted amount is equal to the public deposit.
/// Validate registration. The deposit amount is public, but we must prove that the provided
/// ciphertext encrypts the deposit amount.
#[zkp_program]
fn validate_registration<F: FieldSpec>(
#[linked] encrypted_deposit: BfvSigned<F>,
#[public] public_deposit: Field<F>,
) {
let encrypted_deposit = encrypted_deposit.into_field_elem();
encrypted_deposit.constrain_eq(public_deposit);
}
Refresh balance
To really make this example realistic, we're including a balance refresh operation. We refresh a balance so that
- the ciphertext doesn't overflow its noise budget
- the encrypted plaintext doesn't overflow its plaintext modulus
We need to prove that the fresh balance does indeed have a fresh encoding, and that it encrypts the same value as the existing one.2
/// Validate a balance refresh. We must prove that the two values are equal and that the fresh
/// balance is freshly encoded.
#[zkp_program]
fn validate_refresh_balance<F: FieldSpec>(
#[linked] existing_balance: BfvSigned<F>,
#[linked] fresh_balance: BfvSigned<F>,
) {
fresh_balance.constrain_fresh_encoding();
fresh_balance
.into_field_elem()
.constrain_eq(existing_balance.into_field_elem());
}
App
For convenience, we'll wrap up the FHE and ZKP programs into an application type, this way each party can instantiate the same programs and run operations with the same paramaters.
pub struct App(FheZkpApplication);
impl App {
pub fn new() -> Result<Self> {
let app = Compiler::new()
.fhe_program(transfer_to)
.fhe_program(transfer_from)
.fhe_program(deposit_to)
.zkp_backend::<BulletproofsBackend>()
.zkp_program(validate_transfer)
.zkp_program(validate_registration)
.zkp_program(validate_refresh_balance)
.compile()?;
Ok(Self(app))
}
pub fn get_transfer_zkp(&self) -> &CompiledZkpProgram {
self.0.get_zkp_program(validate_transfer).unwrap()
}
pub fn get_registration_zkp(&self) -> &CompiledZkpProgram {
self.0.get_zkp_program(validate_registration).unwrap()
}
pub fn get_refresh_balance_zkp(&self) -> &CompiledZkpProgram {
self.0.get_zkp_program(validate_refresh_balance).unwrap()
}
pub fn get_transfer_to_fhe(&self) -> &CompiledFheProgram {
self.0.get_fhe_program(transfer_to).unwrap()
}
pub fn get_transfer_from_fhe(&self) -> &CompiledFheProgram {
self.0.get_fhe_program(transfer_from).unwrap()
}
pub fn get_deposit_to_fhe(&self) -> &CompiledFheProgram {
self.0.get_fhe_program(deposit_to).unwrap()
}
pub fn params(&self) -> &Params {
self.0.params()
}
}
Transactions
Since we're imagining a blockchain like setting, users will act by sending atomic transactions to the chain. Let's piece together what the transaction types will look like.
Transfer
Recall a user needs to send over two ciphertexts encrypting the transaction amount. Of course, they'll also need to send the validity proof and a way to identify the sender and receiver.
/// A way to identify a user.
type Username = String;
/// A private transfer transaction.
///
/// The SDLP in the linked proof proves that the ciphertexts are valid, fresh encryptions of the
/// same value. The R1CS ZKP in the linked proof proves that the amount encrypted does not exceed
/// the sender's current balance.
#[derive(Clone)]
pub struct Transfer {
proof: LinkedProof,
// Transfer amount encrypted under sender's key
encrypted_amount_sender: Ciphertext,
// Transfer amount encrypted under receiver's key
encrypted_amount_receiver: Ciphertext,
sender: Username,
receiver: Username,
}
Deposit
A registration will rely on a deposit, so let's define this type first. Since the amount is public, depositing into an existing account doesn't have any proof requirements.
/// A public deposit transaction.
#[derive(Clone)]
pub struct Deposit {
public_amount: i64,
name: Username,
}
Registration
As mentioned, the registration is an initial deposit with a matching initial encrypted balance. In addition, the computing party needs to know the user's public key to run FHE programs on their ciphertexts.
/// A register transaction.
///
/// The SDLP in the linked proof proves that the ciphertext is a valid, fresh encryption. The R1CS
/// ZKP in the linked proof proves that the amount encrypted matches the public amount deposited.
#[derive(Clone)]
pub struct Register {
proof: LinkedProof,
public_key: PublicKey,
encrypted_amount: Ciphertext,
deposit: Deposit,
}
Refresh balance
Lastly, refreshing a balance requires the new ciphertext and its accompanying proof of validity.
/// A refresh private balance transaction.
///
/// The SDLP in the linked proof proves that the fresh balance is a valid, fresh encryption (to
/// avoid overflowing the noise budget). The R1CS ZKP in the linked proof proves that the new
/// encryption is also _freshly encoded_ (to avoid overflowing the plaintext modulus) and that it
/// matches the existing value on chain.
#[derive(Clone)]
pub struct RefreshBalance {
proof: LinkedProof,
fresh_balance: Ciphertext,
name: Username,
}
Parties
Let's first define the different parties. Well have User struct for parties
that wish to use the private transactions system, and we'll have a Chain
struct for the computing party, in this case mimicking a blockchain.
/// Perspective of a user.
pub struct User {
pub name: Username,
pub public_key: PublicKey,
private_key: PrivateKey,
// This app holds ZKP programs used to make proofs
app: App,
// The runtime is used for encryption/decryption and creating proofs
runtime: FheZkpRuntime<BulletproofsBackend>,
}
impl User {
pub fn new(name: &str) -> Result<Self> {
let app = App::new()?;
let runtime = FheZkpRuntime::new(app.params(), &BulletproofsBackend::new())?;
let (public_key, private_key) = runtime.generate_keys()?;
Ok(Self {
name: name.to_string(),
runtime,
public_key,
private_key,
app,
})
}
}
/// A chain transaction.
pub enum Transaction {
Register(Register),
Deposit(Deposit),
Transfer(Transfer),
RefreshBalance(RefreshBalance),
}
/// Perspective of the blockchain, basically just a place where user's encrypted balances are
/// stored and transparent FHE computations take place in the form of atomic transactions.
///
/// In this simple example, assume read-only references `&Chain` provide "call" functionalities,
/// i.e. non-mutating methods for reading chain data, and mutable references `&mut Chain` provide
/// "send" functionalities, i.e. sending transactions that can mutate chain data.
pub struct Chain {
/// The current balances
balances: HashMap<Username, Ciphertext>,
/// The user's public keys
keys: HashMap<Username, PublicKey>,
/// Ledger of transactions
ledger: Vec<Transaction>,
/// App holding FHE and ZKP programs
app: App,
/// Runtime to run FHE programs and verify proofs
runtime: FheZkpRuntime<BulletproofsBackend>,
}
impl Chain {
pub fn new() -> Result<Self> {
let app = App::new()?;
let runtime = FheZkpRuntime::new(app.params(), &BulletproofsBackend::new())?;
Ok(Self {
balances: HashMap::new(),
keys: HashMap::new(),
ledger: Vec::new(),
runtime,
app,
})
}
}
User
Now let's go over the user's perspective and how they'll construct the various transactions.
Registration
To register, a user will use the LinkedProofBuilder to encrypt their initial
deposit and link it to the ZKP proving its equality to the public amount. We'll
also add some print statements in so that we can watch what happens when we run
main below.
impl User {
/// Create a public deposit to a private balance.
pub fn create_deposit(&self, amount: i64) -> Deposit {
Deposit {
public_amount: amount,
name: self.name.clone(),
}
}
/// Create a register transaction.
pub fn create_register(&self, initial_deposit: i64) -> Result<Register> {
let mut builder = self.runtime.linkedproof_builder();
// Encrypt deposit amount
println!(
" {}: encrypting and linking {}",
self.name, initial_deposit
);
let (amount_enc, amount_linked) =
builder.encrypt_returning_link(&Signed::from(initial_deposit), &self.public_key)?;
// Create registration proof
println!(" {}: creating registration linkedproof", self.name);
let proof = builder
.zkp_program(self.app.get_registration_zkp())?
.linked_input(amount_linked)
.public_input(BulletproofsField::from(initial_deposit))
.build()?;
Ok(Register {
proof,
encrypted_amount: amount_enc,
public_key: self.public_key.clone(),
deposit: self.create_deposit(initial_deposit),
})
}
}
Transfer
To create a transfer, the user needs to encrypt the transaction under the
receiver's public key; they can read this off the chain, since registered users
will have their public keys stored there. They also will need to read off their
current encrypted balance to link it to the validate_transfer proof.
Here, we'll make use of some of the more exotic methods of the LinkedProofBuilder;
after calling encrypt_returning_link to link the transaction amount to the
ZKP, we'll call reencrypt which implicitly proves that the returned
ciphertexts encrypt the same plaintext message. Both of these methods also
implicitly prove that the returned ciphertexts are fresh encryptions. Finally
we'll call decrypt_returning_link to link the current balance to the ZKP.
impl User {
/// Create a private, validated transfer to send to another user.
pub fn create_transfer<U: Into<Username>>(
&self,
chain: &Chain,
amount: i64,
receiver: U,
) -> Result<Transfer> {
let receiver = receiver.into();
let mut builder = self.runtime.linkedproof_builder();
// Encrypt tx amount under sender's public key.
println!(" {}: encrypting {} under own key", self.name, amount);
let (encrypted_amount_sender, amount_linked) =
builder.encrypt_returning_link(&Signed::from(amount), &self.public_key)?;
// Encrypt tx amount under receiver's public key, implicitly proving that the two
// ciphertexts encrypt the same value.
println!(
" {}: encrypting {} under receiver key",
self.name, amount
);
let recv_pk = chain.keys.get(&receiver).unwrap();
let encrypted_amount_receiver = builder.reencrypt(&amount_linked, recv_pk)?;
// Decrypt current balance, needed to prove tx validity
let balance_enc = chain.balances.get(&self.name).unwrap();
let (balance, balance_linked) =
builder.decrypt_returning_link::<Signed>(balance_enc, &self.private_key)?;
// Create transfer proof
println!(
" {}: creating transfer linkedproof, proving {} <= {}",
self.name, amount, balance
);
let proof = builder
.zkp_program(self.app.get_transfer_zkp())?
.linked_input(amount_linked)
.linked_input(balance_linked)
.build()?;
Ok(Transfer {
proof,
sender: self.name.clone(),
receiver,
encrypted_amount_sender,
encrypted_amount_receiver,
})
}
}
Refresh balance
Refreshing a balance requires linking both the fresh encryption and the existing ciphertext. We'll again read the existing ciphertext off the chain.
impl User {
/// Create a refresh balance transaction.
pub fn create_refresh_balance(&self, chain: &Chain) -> Result<RefreshBalance> {
let mut builder = self.runtime.linkedproof_builder();
// Decrypt current balance, returning a link to the underlying message
let balance_encrypted = chain.balances.get(&self.name).unwrap();
let (balance, existing_link) =
builder.decrypt_returning_link::<Signed>(balance_encrypted, &self.private_key)?;
// Re-encrypt the current balance, returning a link to the underlying message
println!(" {}: re-encrypting balance of {}", self.name, balance);
let (fresh_balance, fresh_link) =
builder.encrypt_returning_link(&balance, &self.public_key)?;
// Generate proof that the ciphertexts encrypt the same underlying message and that
// the new one has a fresh noise budget and fresh encoding.
println!(" {}: creating refresh balance linkedproof", self.name);
let proof = builder
.zkp_program(self.app.get_refresh_balance_zkp())?
.linked_input(existing_link)
.linked_input(fresh_link)
.build()?;
Ok(RefreshBalance {
proof,
fresh_balance,
name: self.name.clone(),
})
}
}
Astute readers may have noticed that we've proven ciphertext equality within the
ZKP program, rather than calling builder.reencrypt(existing_link) as we did
for the transfer linked proof. By calling encrypt_returning_link we are
creating a new freshly encoded plaintext, and then creating a freshly
encrypted ciphertext of it. The reencrypt method does not create a freshly
encoded plaintext, rather it re-encrypts the exact plaintext of the existing
message. (And since our ZKP program constrains the linked value to a fresh
encoding, this would fail for anything but initial balances.)
Chain
Next we'll show how the chain will process these transactions.
Registration
Here's how the chain will verify a registration and, if successful, update its state.
impl Chain {
pub fn register(&mut self, register: Register) -> Result<()> {
self.ledger.push(Transaction::Register(register.clone()));
let Register {
proof,
encrypted_amount,
public_key,
deposit,
} = register;
// First, verify that the encrypted amount matches the public amount
let mut builder = self.runtime.linkedproof_verification_builder();
builder.encrypt_returning_link::<Signed>(&encrypted_amount, &public_key)?;
builder
.zkp_program(self.app.get_registration_zkp())?
.proof(proof)
.public_input(BulletproofsField::from(deposit.public_amount))
.verify()?;
// Register the user's public key
self.keys.insert(deposit.name.clone(), public_key);
// Set the initial encrypted balance
self.balances.insert(deposit.name, encrypted_amount);
Ok(())
}
}
Deposit
Once a user is registered, they can make more deposits. The chain will use the
deposit_to FHE program, adding the public plaintext amount to the encrypted
balance.
impl Chain {
pub fn deposit(&mut self, deposit: Deposit) -> Result<()> {
self.ledger.push(Transaction::Deposit(deposit.clone()));
let Deposit {
public_amount,
name,
} = deposit;
// Deposit into the user's balance
let pk = self.keys.get(&name).unwrap();
let curr_bal = self.balances.get_mut(&name).unwrap();
*curr_bal = self
.runtime
.run::<FheProgramInput>(
self.app.get_deposit_to_fhe(),
vec![curr_bal.clone().into(), Signed::from(public_amount).into()],
pk,
)?
.remove(0);
Ok(())
}
}
Transfer
For a private transfer, the chain needs to verify the inputs by verifying the accompanying proof, and then run two FHE programs, one for the sender and one for the receiver.
impl Chain {
pub fn transfer(&mut self, transfer: Transfer) -> Result<()> {
self.ledger.push(Transaction::Transfer(transfer.clone()));
let Transfer {
proof,
encrypted_amount_sender,
encrypted_amount_receiver,
sender,
receiver,
} = transfer;
// First verify the transfer is valid
let mut builder = self.runtime.linkedproof_verification_builder();
let link = builder.encrypt_returning_link::<Signed>(
&encrypted_amount_sender,
self.keys.get(&sender).unwrap(),
)?;
builder.reencrypt(
&link,
&encrypted_amount_receiver,
self.keys.get(&receiver).unwrap(),
)?;
builder.decrypt_returning_link::<Signed>(self.balances.get(&sender).unwrap())?;
builder
.zkp_program(self.app.get_transfer_zkp())?
.proof(proof)
.verify()?;
// Update the sender's balance:
let sender_pk = self.keys.get(&sender).unwrap();
let sender_balance = self.balances.get_mut(&sender).unwrap();
*sender_balance = self
.runtime
.run(
self.app.get_transfer_from_fhe(),
vec![sender_balance.clone(), encrypted_amount_sender],
sender_pk,
)?
.remove(0);
// Update receiver's balance
let receiver_pk = self.keys.get(&receiver).unwrap();
let receiver_balance = self.balances.get_mut(&receiver).unwrap();
*receiver_balance = self
.runtime
.run(
self.app.get_transfer_to_fhe(),
vec![receiver_balance.clone(), encrypted_amount_receiver],
receiver_pk,
)?
.remove(0);
Ok(())
}
}
Refresh balance
Finally, to refresh a balance the chain simply needs to verify the proof and then overwrite the existing balance.
impl Chain {
pub fn refresh_balance(&mut self, refresh_balance: RefreshBalance) -> Result<()> {
self.ledger
.push(Transaction::RefreshBalance(refresh_balance.clone()));
let RefreshBalance {
proof,
fresh_balance,
name,
} = refresh_balance;
// Verify the balance refresh is valid
let mut builder = self.runtime.linkedproof_verification_builder();
builder.decrypt_returning_link::<Signed>(self.balances.get(&name).unwrap())?;
builder.encrypt_returning_link::<Signed>(&fresh_balance, self.keys.get(&name).unwrap())?;
builder
.zkp_program(self.app.get_refresh_balance_zkp())?
.proof(proof)
.verify()?;
// Use the freshly encrypted balance
self.balances
.insert(name, fresh_balance)
.expect("User should be registered");
Ok(())
}
}
Run it!
Finally, here's a runnable main function demonstrating the transactions above:
use std::collections::HashMap; use sunscreen::{ bulletproofs::BulletproofsBackend, fhe_program, linked::LinkedProof, types::{ bfv::Signed, zkp::{ AsFieldElement, BfvSigned, BulletproofsField, ConstrainCmp, ConstrainFresh, Field, FieldSpec, }, Cipher, }, zkp_program, zkp_var, Ciphertext, CompiledFheProgram, CompiledZkpProgram, Compiler, FheProgramInput, FheZkpApplication, FheZkpRuntime, Params, PrivateKey, PublicKey, Result, ZkpProgramInput, }; /// Subtract the transaction amount from the sender's balance. #[fhe_program(scheme = "bfv")] fn transfer_from(balance: Cipher<Signed>, tx: Cipher<Signed>) -> Cipher<Signed> { balance - tx } /// Add the transaction amount to the receiver's balance. #[fhe_program(scheme = "bfv")] fn transfer_to(balance: Cipher<Signed>, tx: Cipher<Signed>) -> Cipher<Signed> { balance + tx } /// Add the public transaction amount to a user's balance. #[fhe_program(scheme = "bfv")] fn deposit_to(balance: Cipher<Signed>, deposit: Signed) -> Cipher<Signed> { balance + deposit } /// Validate a transfer transaction. #[zkp_program] fn validate_transfer<F: FieldSpec>( #[linked] tx: BfvSigned<F>, #[linked] sender_balance: BfvSigned<F>, ) { let tx = tx.into_field_elem(); let sender_balance = sender_balance.into_field_elem(); // Transaction amount must be greater than 0. tx.constrain_gt_bounded(zkp_var!(0), 64); // Transaction amount cannot exceed sender's balance. tx.constrain_le_bounded(sender_balance, 64); } /// Validate registration. The deposit amount is public, but we must prove that the provided /// ciphertext encrypts the deposit amount. #[zkp_program] fn validate_registration<F: FieldSpec>( #[linked] encrypted_deposit: BfvSigned<F>, #[public] public_deposit: Field<F>, ) { let encrypted_deposit = encrypted_deposit.into_field_elem(); encrypted_deposit.constrain_eq(public_deposit); } /// Validate a balance refresh. We must prove that the two values are equal and that the fresh /// balance is freshly encoded. #[zkp_program] fn validate_refresh_balance<F: FieldSpec>( #[linked] existing_balance: BfvSigned<F>, #[linked] fresh_balance: BfvSigned<F>, ) { fresh_balance.constrain_fresh_encoding(); fresh_balance .into_field_elem() .constrain_eq(existing_balance.into_field_elem()); } /// A way to identify a user. type Username = String; /// Perspective of a user. pub struct User { pub name: Username, pub public_key: PublicKey, private_key: PrivateKey, // This app holds ZKP programs used to make proofs app: App, // The runtime is used for encryption/decryption and creating proofs runtime: FheZkpRuntime<BulletproofsBackend>, } impl User { pub fn new(name: &str) -> Result<Self> { let app = App::new()?; let runtime = FheZkpRuntime::new(app.params(), &BulletproofsBackend::new())?; let (public_key, private_key) = runtime.generate_keys()?; Ok(Self { name: name.to_string(), runtime, public_key, private_key, app, }) } /// Create a private, validated transfer to send to another user. pub fn create_transfer<U: Into<Username>>( &self, chain: &Chain, amount: i64, receiver: U, ) -> Result<Transfer> { let receiver = receiver.into(); let mut builder = self.runtime.linkedproof_builder(); // Encrypt tx amount under sender's public key. println!(" {}: encrypting {} under own key", self.name, amount); let (encrypted_amount_sender, amount_linked) = builder.encrypt_returning_link(&Signed::from(amount), &self.public_key)?; // Encrypt tx amount under receiver's public key, implicitly proving that the two // ciphertexts encrypt the same value. println!( " {}: encrypting {} under receiver key", self.name, amount ); let recv_pk = chain.keys.get(&receiver).unwrap(); let encrypted_amount_receiver = builder.reencrypt(&amount_linked, recv_pk)?; // Decrypt current balance, needed to prove tx validity let balance_enc = chain.balances.get(&self.name).unwrap(); let (balance, balance_linked) = builder.decrypt_returning_link::<Signed>(balance_enc, &self.private_key)?; // Create transfer proof println!( " {}: creating transfer linkedproof, proving {} <= {}", self.name, amount, balance ); let proof = builder .zkp_program(self.app.get_transfer_zkp())? .linked_input(amount_linked) .linked_input(balance_linked) .build()?; Ok(Transfer { proof, sender: self.name.clone(), receiver, encrypted_amount_sender, encrypted_amount_receiver, }) } /// Create a public deposit to a private balance. pub fn create_deposit(&self, amount: i64) -> Deposit { Deposit { public_amount: amount, name: self.name.clone(), } } /// Create a refresh balance transaction. pub fn create_refresh_balance(&self, chain: &Chain) -> Result<RefreshBalance> { let mut builder = self.runtime.linkedproof_builder(); // Decrypt current balance, returning a link to the underlying message let balance_encrypted = chain.balances.get(&self.name).unwrap(); let (balance, existing_link) = builder.decrypt_returning_link::<Signed>(balance_encrypted, &self.private_key)?; // Re-encrypt the current balance, returning a link to the underlying message println!(" {}: re-encrypting balance of {}", self.name, balance); let (fresh_balance, fresh_link) = builder.encrypt_returning_link(&balance, &self.public_key)?; // Generate proof that the ciphertexts encrypt the same underlying message and that // the new one has a fresh noise budget and fresh encoding. println!(" {}: creating refresh balance linkedproof", self.name); let proof = builder .zkp_program(self.app.get_refresh_balance_zkp())? .linked_input(existing_link) .linked_input(fresh_link) .build()?; Ok(RefreshBalance { proof, fresh_balance, name: self.name.clone(), }) } /// Create a register transaction. pub fn create_register(&self, initial_deposit: i64) -> Result<Register> { let mut builder = self.runtime.linkedproof_builder(); // Encrypt deposit amount println!( " {}: encrypting and linking {}", self.name, initial_deposit ); let (amount_enc, amount_linked) = builder.encrypt_returning_link(&Signed::from(initial_deposit), &self.public_key)?; // Create registration proof println!(" {}: creating registration linkedproof", self.name); let proof = builder .zkp_program(self.app.get_registration_zkp())? .linked_input(amount_linked) .public_input(BulletproofsField::from(initial_deposit)) .build()?; Ok(Register { proof, encrypted_amount: amount_enc, public_key: self.public_key.clone(), deposit: self.create_deposit(initial_deposit), }) } } /// A register transaction. /// /// The SDLP in the linked proof proves that the ciphertext is a valid, fresh encryption. The R1CS /// ZKP in the linked proof proves that the amount encrypted matches the public amount deposited. #[derive(Clone)] pub struct Register { proof: LinkedProof, public_key: PublicKey, encrypted_amount: Ciphertext, deposit: Deposit, } /// A public deposit transaction. #[derive(Clone)] pub struct Deposit { public_amount: i64, name: Username, } /// A private transfer transaction. /// /// The SDLP in the linked proof proves that the ciphertexts are valid, fresh encryptions of the /// same value. The R1CS ZKP in the linked proof proves that the amount encrypted does not exceed /// the sender's current balance. #[derive(Clone)] pub struct Transfer { proof: LinkedProof, // Transfer amount encrypted under sender's key encrypted_amount_sender: Ciphertext, // Transfer amount encrypted under receiver's key encrypted_amount_receiver: Ciphertext, sender: Username, receiver: Username, } /// A refresh private balance transaction. /// /// The SDLP in the linked proof proves that the fresh balance is a valid, fresh encryption (to /// avoid overflowing the noise budget). The R1CS ZKP in the linked proof proves that the new /// encryption is also _freshly encoded_ (to avoid overflowing the plaintext modulus) and that it /// matches the existing value on chain. #[derive(Clone)] pub struct RefreshBalance { proof: LinkedProof, fresh_balance: Ciphertext, name: Username, } /// A chain transaction. pub enum Transaction { Register(Register), Deposit(Deposit), Transfer(Transfer), RefreshBalance(RefreshBalance), } /// Perspective of the blockchain, basically just a place where user's encrypted balances are /// stored and transparent FHE computations take place in the form of atomic transactions. /// /// In this simple example, assume read-only references `&Chain` provide "call" functionalities, /// i.e. non-mutating methods for reading chain data, and mutable references `&mut Chain` provide /// "send" functionalities, i.e. sending transactions that can mutate chain data. pub struct Chain { /// The current balances balances: HashMap<Username, Ciphertext>, /// The user's public keys keys: HashMap<Username, PublicKey>, /// Ledger of transactions ledger: Vec<Transaction>, /// App holding FHE and ZKP programs app: App, /// Runtime to run FHE programs and verify proofs runtime: FheZkpRuntime<BulletproofsBackend>, } impl Chain { pub fn new() -> Result<Self> { let app = App::new()?; let runtime = FheZkpRuntime::new(app.params(), &BulletproofsBackend::new())?; Ok(Self { balances: HashMap::new(), keys: HashMap::new(), ledger: Vec::new(), runtime, app, }) } pub fn register(&mut self, register: Register) -> Result<()> { self.ledger.push(Transaction::Register(register.clone())); let Register { proof, encrypted_amount, public_key, deposit, } = register; // First, verify that the encrypted amount matches the public amount let mut builder = self.runtime.linkedproof_verification_builder(); builder.encrypt_returning_link::<Signed>(&encrypted_amount, &public_key)?; builder .zkp_program(self.app.get_registration_zkp())? .proof(proof) .public_input(BulletproofsField::from(deposit.public_amount)) .verify()?; // Register the user's public key self.keys.insert(deposit.name.clone(), public_key); // Set the initial encrypted balance self.balances.insert(deposit.name, encrypted_amount); Ok(()) } pub fn deposit(&mut self, deposit: Deposit) -> Result<()> { self.ledger.push(Transaction::Deposit(deposit.clone())); let Deposit { public_amount, name, } = deposit; // Deposit into the user's balance let pk = self.keys.get(&name).unwrap(); let curr_bal = self.balances.get_mut(&name).unwrap(); *curr_bal = self .runtime .run::<FheProgramInput>( self.app.get_deposit_to_fhe(), vec![curr_bal.clone().into(), Signed::from(public_amount).into()], pk, )? .remove(0); Ok(()) } pub fn transfer(&mut self, transfer: Transfer) -> Result<()> { self.ledger.push(Transaction::Transfer(transfer.clone())); let Transfer { proof, encrypted_amount_sender, encrypted_amount_receiver, sender, receiver, } = transfer; // First verify the transfer is valid let mut builder = self.runtime.linkedproof_verification_builder(); let link = builder.encrypt_returning_link::<Signed>( &encrypted_amount_sender, self.keys.get(&sender).unwrap(), )?; builder.reencrypt( &link, &encrypted_amount_receiver, self.keys.get(&receiver).unwrap(), )?; builder.decrypt_returning_link::<Signed>(self.balances.get(&sender).unwrap())?; builder .zkp_program(self.app.get_transfer_zkp())? .proof(proof) .verify()?; // Update the sender's balance: let sender_pk = self.keys.get(&sender).unwrap(); let sender_balance = self.balances.get_mut(&sender).unwrap(); *sender_balance = self .runtime .run( self.app.get_transfer_from_fhe(), vec![sender_balance.clone(), encrypted_amount_sender], sender_pk, )? .remove(0); // Update receiver's balance let receiver_pk = self.keys.get(&receiver).unwrap(); let receiver_balance = self.balances.get_mut(&receiver).unwrap(); *receiver_balance = self .runtime .run( self.app.get_transfer_to_fhe(), vec![receiver_balance.clone(), encrypted_amount_receiver], receiver_pk, )? .remove(0); Ok(()) } pub fn refresh_balance(&mut self, refresh_balance: RefreshBalance) -> Result<()> { self.ledger .push(Transaction::RefreshBalance(refresh_balance.clone())); let RefreshBalance { proof, fresh_balance, name, } = refresh_balance; // Verify the balance refresh is valid let mut builder = self.runtime.linkedproof_verification_builder(); builder.decrypt_returning_link::<Signed>(self.balances.get(&name).unwrap())?; builder.encrypt_returning_link::<Signed>(&fresh_balance, self.keys.get(&name).unwrap())?; builder .zkp_program(self.app.get_refresh_balance_zkp())? .proof(proof) .verify()?; // Use the freshly encrypted balance self.balances .insert(name, fresh_balance) .expect("User should be registered"); Ok(()) } pub fn print_ledger(&self) { for (i, tx) in self.ledger.iter().enumerate() { match tx { Transaction::Register(r) => println!("{i}. User {} registered", r.deposit.name,), Transaction::Deposit(d) => { println!("{i}. User {} deposited {}", d.name, d.public_amount) } Transaction::Transfer(t) => println!( "{i}. User {} transferred <ENCRYPTED> to {}", t.sender, t.receiver ), Transaction::RefreshBalance(b) => { println!("{i}. User {} refreshed their balance", b.name) } } } } } pub struct App(FheZkpApplication); impl App { pub fn new() -> Result<Self> { let app = Compiler::new() .fhe_program(transfer_to) .fhe_program(transfer_from) .fhe_program(deposit_to) // These params are not necessary to run the example, but they do shave a few // minutes off the runtime. In practice, you probably want to use the default // parameters provided by the compiler. The ones set here will result in balances // needing to be refreshed more often. .with_params(&Params { lattice_dimension: 1024, coeff_modulus: vec![0x7e00001], plain_modulus: 512, scheme_type: sunscreen::SchemeType::Bfv, security_level: sunscreen::SecurityLevel::TC128, }) .zkp_backend::<BulletproofsBackend>() .zkp_program(validate_transfer) .zkp_program(validate_registration) .zkp_program(validate_refresh_balance) .compile()?; Ok(Self(app)) } pub fn get_transfer_zkp(&self) -> &CompiledZkpProgram { self.0.get_zkp_program(validate_transfer).unwrap() } pub fn get_registration_zkp(&self) -> &CompiledZkpProgram { self.0.get_zkp_program(validate_registration).unwrap() } pub fn get_refresh_balance_zkp(&self) -> &CompiledZkpProgram { self.0.get_zkp_program(validate_refresh_balance).unwrap() } pub fn get_transfer_to_fhe(&self) -> &CompiledFheProgram { self.0.get_fhe_program(transfer_to).unwrap() } pub fn get_transfer_from_fhe(&self) -> &CompiledFheProgram { self.0.get_fhe_program(transfer_from).unwrap() } pub fn get_deposit_to_fhe(&self) -> &CompiledFheProgram { self.0.get_fhe_program(deposit_to).unwrap() } pub fn params(&self) -> &Params { self.0.params() } } fn main() -> Result<()> { println!("Starting a new chain..."); let mut chain = Chain::new()?; println!(); println!("Running Alice's transactions..."); let alice = User::new("Alice")?; let deposit = 100; println!("Registering with a deposit of {deposit}"); chain.register(alice.create_register(deposit)?)?; let deposit = 50; println!("Depositing an extra {deposit}"); chain.deposit(alice.create_deposit(deposit))?; println!(); println!("Running Bob's transactions..."); let bob = User::new("Bob")?; let deposit = 100; println!("Registering with a deposit of {deposit}"); chain.register(bob.create_register(deposit)?)?; let tx = 50; println!("Transfering {tx} to Alice"); chain.transfer(bob.create_transfer(&chain, tx, "Alice")?)?; println!(); println!("Refreshing Alice's balance..."); let refresh_balance = alice.create_refresh_balance(&chain)?; chain.refresh_balance(refresh_balance)?; println!("Done!"); println!(); println!("========================== Ledger =========================="); println!(); chain.print_ledger(); Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn main_works() -> Result<()> { main() } }
It's worth noting that we are also implicitly relying on the fact that FHE programs are deterministic. You could imagine a scenario where one tries to accomplish this by having a trusted party decrypt the inputs, perform the computation, and then encrypt the result - but that encryption of the result relies on randomness, which is generally not available for a consensus-driven compute setting like a blockchain. Because FHE programs are deterministic, any validators running the computation will always get the exact same ciphertext result, allowing consensus to proceed.
In practice, you may wish to restrict the ciphertexts of transaction or deposit amounts to also be fresh encodings. With some additional metadata on chain indicating how many modifications have been performed on a user's balance, you could effectively track how close the coefficients are to the plaintext modulus, and then restrict a user from making transactions unless they refresh their balance.