Skip to main content

Tutorial: Encrypted Counter

This tutorial walks through building an encrypted counter program from scratch.

What We’ll Build

A simple counter that:
  • Stores an encrypted value
  • Increments by an encrypted amount
  • Never exposes the actual values on-chain

Step 1: Project Setup

cargo new --lib fhe-counter
cd fhe-counter
Cargo.toml:
[package]
name = "fhe-counter"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]

[dependencies]
privora-sdk-program = { git = "https://github.com/privora-xyz/privora" }
solana-program = "2.0"
borsh = "1.5"

[dev-dependencies]
privora-sdk-testing = { git = "https://github.com/privora-xyz/privora" }

[features]
no-entrypoint = []

Step 2: Define State

src/lib.rs:
use privora_sdk_program::prelude::*;
use borsh::{BorshSerialize, BorshDeserialize};

// CRITICAL: Set up FHE allocator
privora_sdk_program::setup_fhe_allocator!();

/// Counter state - only stores a 32-byte hash reference
#[derive(BorshSerialize, BorshDeserialize)]
pub struct Counter {
    pub value_ref: EncryptedRef<u64>,
}

impl Counter {
    pub const LEN: usize = 32;
}

Step 3: Process Instructions

solana_program::entrypoint!(process_instruction);

pub fn process_instruction(
    _program_id: &Pubkey,
    accounts: &[AccountInfo],
    instruction_data: &[u8],
) -> ProgramResult {
    let accounts_iter = &mut accounts.iter();
    let counter_account = next_account_info(accounts_iter)?;

    match instruction_data[0] {
        // Initialize
        0 => initialize(counter_account, &instruction_data[1..]),
        // Increment
        1 => increment(counter_account, &instruction_data[1..]),
        _ => Err(ProgramError::InvalidInstructionData),
    }
}

Step 4: Initialize Instruction

fn initialize(
    counter_account: &AccountInfo,
    data: &[u8],
) -> ProgramResult {
    // Parse initial value hash from instruction data
    let initial_hash: [u8; 32] = data[0..32]
        .try_into()
        .map_err(|_| ProgramError::InvalidInstructionData)?;

    let counter = Counter {
        value_ref: EncryptedRef::from_hash(initial_hash),
    };

    counter.serialize(&mut *counter_account.data.borrow_mut())?;
    msg!("Counter initialized");
    Ok(())
}

Step 5: Increment Instruction

fn increment(
    counter_account: &AccountInfo,
    data: &[u8],
) -> ProgramResult {
    // Parse increment hash from instruction data
    let increment_hash: [u8; 32] = data[0..32]
        .try_into()
        .map_err(|_| ProgramError::InvalidInstructionData)?;

    let increment_ref = EncryptedRef::<u64>::from_hash(increment_hash);

    // Load current counter
    let mut counter = Counter::try_from_slice(&counter_account.data.borrow())?;

    // Load encrypted values
    let current_value = counter.value_ref.load()?;
    let increment = increment_ref.load()?;

    // Perform encrypted addition
    let new_value = current_value.add(&increment)?;

    // Store result and update counter
    counter.value_ref = new_value.store()?;
    counter.serialize(&mut *counter_account.data.borrow_mut())?;

    msg!("Counter incremented");
    Ok(())
}

Step 6: Write Tests

tests/counter_test.rs:
use privora_sdk_testing::prelude::*;
use solana_sdk::{
    instruction::{AccountMeta, Instruction},
    signature::Keypair,
    signer::Signer,
    transaction::Transaction,
};

const PROGRAM_ID: Pubkey = solana_pubkey::pubkey!("Counter111111111111111111111111111111111111");

#[test]
fn test_counter() {
    let mut env = FheTestEnv::new();
    env.deploy_program(PROGRAM_ID, include_bytes!("../target/deploy/fhe_counter.so"));

    let payer = env.create_funded_keypair();
    let counter = Keypair::new();

    // Create counter account
    let rent = env.minimum_balance_for_rent_exemption(32);
    let create_ix = solana_sdk::system_instruction::create_account(
        &payer.pubkey(),
        &counter.pubkey(),
        rent,
        32,
        &PROGRAM_ID,
    );

    // Initialize with encrypted 100
    let initial_hash = env.encrypt_and_submit_u64(100);
    let mut init_data = vec![0u8];
    init_data.extend_from_slice(&initial_hash);

    let init_ix = Instruction {
        program_id: PROGRAM_ID,
        accounts: vec![AccountMeta::new(counter.pubkey(), false)],
        data: init_data,
    };

    env.sync_data_store_to_syscalls();
    let tx = Transaction::new_signed_with_payer(
        &[create_ix, init_ix],
        Some(&payer.pubkey()),
        &[&payer, &counter],
        env.latest_blockhash(),
    );
    env.svm.send_transaction(tx).unwrap();
    env.sync_data_store_from_syscalls();

    // Increment by encrypted 50
    let increment_hash = env.encrypt_and_submit_u64(50);
    let mut inc_data = vec![1u8];
    inc_data.extend_from_slice(&increment_hash);

    let inc_ix = Instruction {
        program_id: PROGRAM_ID,
        accounts: vec![AccountMeta::new(counter.pubkey(), false)],
        data: inc_data,
    };

    env.sync_data_store_to_syscalls();
    let tx = Transaction::new_signed_with_payer(
        &[inc_ix],
        Some(&payer.pubkey()),
        &[&payer],
        env.latest_blockhash(),
    );
    env.svm.send_transaction(tx).unwrap();
    env.sync_data_store_from_syscalls();

    // Verify: 100 + 50 = 150
    let account_data = env.svm.get_account(&counter.pubkey()).unwrap();
    let result_hash: [u8; 32] = account_data.data[0..32].try_into().unwrap();
    env.assert_encrypted_eq_u64(&result_hash, 150);
}

Step 7: Build and Test

# Build program
cargo build-sbf

# Run tests
cargo test

Key Takeaways

  1. Always set up the allocator with setup_fhe_allocator!()
  2. Store references, not ciphertexts in accounts
  3. Load, compute, store is the FHE workflow
  4. Sync data store before and after transactions in tests

Next Steps