Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Encrypt Developer Guide

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Encrypt enables smart contracts to compute on encrypted data without ever decrypting it on-chain. Your program operates on ciphertexts — the actual values are never visible to validators, indexers, or anyone else.

How It Works

  1. You write FHE logic using the #[encrypt_fn] DSL — it looks like normal Rust
  2. The macro compiles it into a computation graph (a DAG of FHE operations)
  3. On-chain, execute_graph creates output ciphertext accounts and emits events
  4. Off-chain, the executor evaluates the graph using real FHE and commits results
  5. When needed, you request decryption — the decryptor responds with plaintext
#![allow(unused)]
fn main() {
#[encrypt_fn]
fn transfer(from: EUint64, to: EUint64, amount: EUint64) -> (EUint64, EUint64) {
    let has_funds = from >= amount;
    let new_from = if has_funds { from - amount } else { from };
    let new_to = if has_funds { to + amount } else { to };
    (new_from, new_to)
}
}

This compiles into an FHE computation graph that operates on encrypted balances. Nobody on-chain ever sees the actual amounts.

What You’ll Learn

  • Getting Started: Install dependencies, create your first encrypted program
  • Tutorial: Build a complete confidential voting application step by step
  • DSL Reference: All supported types, operations, and patterns
  • On-Chain Integration: Ciphertext accounts, access control, graph execution, decryption
  • Framework Guides: Pinocchio, Anchor, and Native examples
  • Testing: Local test framework, CLI tools, mock vs real FHE
  • Reference: Complete instruction, account, event, and fee documentation

Supported Frameworks

Encrypt works with all three major Solana program frameworks:

FrameworkSDK CrateBest For
Pinocchioencrypt-pinocchioMaximum CU efficiency, #![no_std] programs
Anchorencrypt-anchorRapid development, declarative accounts
Nativeencrypt-nativesolana-program users, no framework lock-in

All three use the same #[encrypt_fn] DSL and the same EncryptCpi trait.

Installation

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Prerequisites

  • Rust (edition 2024): curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  • Solana CLI 3.x+: sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"
  • Bun (for TypeScript clients): curl -fsSL https://bun.sh/install | bash

Add Dependencies

For Pinocchio Programs

[dependencies]
encrypt-types = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-dsl = { package = "encrypt-solana-dsl", git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-pinocchio = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
pinocchio = "0.10"

[dev-dependencies]
encrypt-solana-test = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }

For Anchor Programs

[dependencies]
encrypt-types = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-dsl = { package = "encrypt-solana-dsl", git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-anchor = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
anchor-lang = "0.32"

[dev-dependencies]
encrypt-solana-test = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }

For Native Programs

[dependencies]
encrypt-types = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-dsl = { package = "encrypt-solana-dsl", git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-native = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
solana-program = "4"

[dev-dependencies]
encrypt-solana-test = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }

Client SDKs

Rust gRPC Client

[dependencies]
encrypt-solana-client = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

TypeScript gRPC Client

bun add @encrypt.xyz/pre-alpha-solana-client

Pre-Alpha Environment

The Encrypt program is deployed to Solana devnet. An executor is running at:

ResourceEndpoint
Encrypt gRPChttps://pre-alpha-dev-1.encrypt.ika-network.net:443
Solana RPChttps://api.devnet.solana.com
Program IDTODO: will be updated after deployment

No local executor or validator setup needed — just connect to devnet.

Quick Start

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Build your first encrypted program in 5 minutes.

1. Write an FHE Function

#![allow(unused)]
fn main() {
use encrypt_dsl::prelude::*;

#[encrypt_fn]
fn add(a: EUint64, b: EUint64) -> EUint64 {
    a + b
}
}

The #[encrypt_fn] macro generates:

  • add() — returns the serialized computation graph bytes
  • AddCpi — an extension trait on EncryptCpi with method ctx.add(a, b, output)?

2. Use It in Your Program

Pinocchio

#![allow(unused)]
fn main() {
use encrypt_pinocchio::EncryptContext;

let ctx = EncryptContext { /* ... */ };
ctx.add(input_a, input_b, output_ct)?;
}

Anchor

#![allow(unused)]
fn main() {
use encrypt_anchor::EncryptContext;

let ctx = EncryptContext { /* ... */ };
ctx.add(input_a.to_account_info(), input_b.to_account_info(), output.to_account_info())?;
}

Native

#![allow(unused)]
fn main() {
use encrypt_native::EncryptContext;

let ctx = EncryptContext { /* ... */ };
ctx.add(input_a.clone(), input_b.clone(), output.clone())?;
}

3. Test It

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use encrypt_solana_test::EncryptTestContext;
    use encrypt_types::encrypted::Uint64;

    #[test]
    fn test_add() {
        let mut ctx = EncryptTestContext::new_default();
        let user = ctx.new_funded_keypair();

        let a = ctx.create_input::<Uint64>(10, &user.pubkey());
        let b = ctx.create_input::<Uint64>(32, &user.pubkey());

        let graph = super::add();
        let outputs = ctx.execute_and_commit(&graph, &[a, b], 1, &[], &user);

        let result = ctx.decrypt::<Uint64>(&outputs[0], &user);
        assert_eq!(result, 42);
    }
}
}

4. Client SDK (gRPC)

Submit encrypted inputs and read ciphertexts via the gRPC client:

Rust

#![allow(unused)]
fn main() {
use encrypt_solana_client::grpc::{EncryptClient, TypedInput};
use encrypt_types::encrypted::{Uint64, Bool};

// Connect to pre-alpha endpoint
let mut client = EncryptClient::connect().await?;

// Create a single encrypted input
let ct = client.create_input::<Uint64>(42u64, &program_id, &network_key).await?;

// Create batch inputs (one proof covers all)
let cts = client.create_inputs(
    &[TypedInput::new::<Uint64>(&10u64), TypedInput::new::<Bool>(&true)],
    &program_id, &network_key,
).await?;

// Read a ciphertext off-chain (signs request with keypair)
let result = client.read_ciphertext(&ct, &reencryption_key, epoch, &keypair).await?;
// result.value = plaintext bytes (mock) or re-encrypted ciphertext (production)
// result.fhe_type, result.digest
}

TypeScript

import { createEncryptClient, encodeReadCiphertextMessage, Chain } from "@encrypt.xyz/pre-alpha-solana-client/grpc";

const client = createEncryptClient();

// Create encrypted input
const { ciphertextIdentifiers } = await client.createInput({
  chain: Chain.SOLANA,
  inputs: [{ ciphertextBytes: ciphertext, fheType: 4 }],
  proof: proofBytes,
  authorized: programId.toBytes(),
  networkEncryptionPublicKey: networkKey,
});

// Read ciphertext off-chain
const msg = encodeReadCiphertextMessage(Chain.SOLANA, ctId, reencryptionKey, epoch);
const result = await client.readCiphertext({ message: msg, signature, signer });

What Happens Under the Hood

  1. Your program calls execute_graph → on-chain creates output ciphertext accounts (status=PENDING)
  2. The executor detects the event → evaluates the computation graph → calls commit_ciphertext (status=VERIFIED)
  3. When you call request_decryption → the decryptor responds with the plaintext result
  4. Your program reads the result from the DecryptionRequest account
  5. Off-chain reads via read_ciphertext gRPC — public ciphertexts are open, private ones require signed request

In test mode, EncryptTestContext handles all of this automatically via process_pending().

Pre-Alpha Environment

ResourceEndpoint
Encrypt gRPCpre-alpha-dev-1.encrypt.ika-network.net:443 (TLS)
Solana NetworkDevnet (https://api.devnet.solana.com)
Program ID4ebfzWdKnrnGseuQpezXdG8yCdHqwQ1SSBHD3bWArND8

Core Concepts

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Ciphertext

A ciphertext is an encrypted value stored on-chain. It’s a regular Solana keypair account (not a PDA) owned by the Encrypt program. The account pubkey IS the ciphertext identifier.

Ciphertext account (98 bytes):
  ciphertext_digest(32)              — hash of the actual encrypted blob
  authorized(32)                     — who can use this (zero = public)
  network_encryption_public_key(32)  — FHE key it was encrypted under
  fhe_type(1)                        — EBool, EUint64, etc.
  status(1)                          — Pending(0) or Verified(1)

Ciphertexts are created in three ways:

  • Authority input (create_input_ciphertext): user submits encrypted data + ZK proof → executor verifies → creates on-chain
  • Plaintext (create_plaintext_ciphertext): user provides plaintext value → encrypted off-chain by executor
  • Graph output (execute_graph): computation produces new ciphertexts (status=PENDING until executor commits)

Computation Graph

FHE operations are compiled into a computation graph — a DAG of operations:

Input(a) ──┐
            ├── Op(Add) ── Output
Input(b) ──┘

The #[encrypt_fn] macro compiles your Rust code into this graph at compile time. The graph is serialized into the execute_graph instruction data. The executor evaluates it off-chain using real FHE.

Executor & Decryptor

The executor and decryptor are off-chain services managed by the Encrypt network:

  • Executor: listens for GraphExecuted events, evaluates computation graphs, commits results on-chain
  • Decryptor: listens for DecryptionRequested events, performs threshold decryption, writes plaintext results on-chain

In the pre-alpha environment, these are hosted at pre-alpha-dev-1.encrypt.ika-network.net:443. You don’t need to run them — just submit encrypted inputs via gRPC and let the network handle the rest.

For local testing, EncryptTestContext simulates both services in-process via process_pending().

Access Control

Every ciphertext has an authorized field:

  • authorized = [0; 32]public — anyone can compute on it or decrypt it
  • authorized = <pubkey> → only that address can use it

Access is managed via:

  • transfer_ciphertext: change who’s authorized
  • copy_ciphertext: create a copy with different authorization
  • make_public: set authorized to zero (irreversible)

Digest Verification

When requesting decryption, the ciphertext_digest is stored in the DecryptionRequest as a snapshot. At reveal time, verify the digest matches to ensure the ciphertext wasn’t updated between request and response:

#![allow(unused)]
fn main() {
let digest = ctx.request_decryption(request_acct, ciphertext)?;
proposal.pending_digest = digest;  // store for later

// ... later, at reveal time ...
let value = read_decrypted_verified::<Uint64>(req_data, &proposal.pending_digest)?;
}

Tutorial: Confidential Voting

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

This tutorial builds a complete confidential voting program on Solana using Encrypt. Individual votes are encrypted – nobody can see how anyone voted – but the final tally is computed via FHE and can be decrypted by the proposal authority.

What You Will Build

A Solana program with five instructions:

InstructionDescription
create_proposalCreates a proposal with two encrypted-zero tallies (yes, no)
cast_voteAdds an encrypted vote to the tally via FHE computation
close_proposalAuthority closes voting
request_tally_decryptionAuthority requests decryption of yes or no tally
reveal_tallyAuthority reads decrypted result and writes plaintext to proposal

How It Works

  1. The authority creates a proposal. Two ciphertext accounts are initialized to encrypted zero (EUint64).
  2. Each voter provides an encrypted boolean vote (EBool): 1 = yes, 0 = no.
  3. The cast_vote_graph FHE function conditionally increments the correct counter:
    • If vote == 1: yes_count += 1, no_count unchanged
    • If vote == 0: no_count += 1, yes_count unchanged
  4. The tally ciphertext accounts are updated in-place (update mode) – the same account serves as both input and output.
  5. A VoteRecord PDA prevents double-voting. Its existence proves the voter already voted.
  6. After closing, the authority requests decryption and verifies the result against a stored digest.

Key Concepts Covered

  • #[encrypt_fn] – writing FHE computation as normal Rust
  • Plaintext ciphertext creation – initializing encrypted zeros via create_plaintext_typed
  • Update mode – passing the same account as both input and output to execute_graph
  • Digest verification – store-and-verify pattern for safe decryption
  • EncryptTestContext – testing the full lifecycle in a single test

Framework Variants

The tutorial uses Pinocchio for maximum CU efficiency. Equivalent examples exist for all three frameworks:

FrameworkSource
Pinocchiochains/solana/examples/confidential-voting-pinocchio/
Anchorchains/solana/examples/confidential-voting-anchor/
Nativechains/solana/examples/confidential-voting-native/

Prerequisites

Create the Program

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Cargo.toml

Create a new Solana program crate with Encrypt dependencies:

[package]
name = "confidential-voting-pinocchio"
version = "0.1.0"
edition = "2024"

[dependencies]
encrypt-types = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-dsl = { package = "encrypt-solana-dsl", git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-pinocchio = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
pinocchio = "0.10"
pinocchio-system = "0.5"

[dev-dependencies]
encrypt-solana-test = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }

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

Key crates:

  • encrypt-dsl (actually encrypt-solana-dsl) – the #[encrypt_fn] macro that generates both the computation graph and the CPI extension trait
  • encrypt-pinocchioEncryptContext and account helpers for Pinocchio programs
  • encrypt-types – FHE types (EUint64, EBool, Uint64) and graph utilities

lib.rs Skeleton

#![allow(unused)]
#![allow(unexpected_cfgs)]

fn main() {
use encrypt_dsl::prelude::encrypt_fn;
use encrypt_pinocchio::accounts::{self, DecryptionRequestStatus};
use encrypt_pinocchio::EncryptContext;
use encrypt_types::encrypted::{EBool, EUint64, Uint64};
use pinocchio::{
    cpi::{Seed, Signer},
    entrypoint,
    error::ProgramError,
    AccountView, Address, ProgramResult,
};
use pinocchio_system::instructions::CreateAccount;

entrypoint!(process_instruction);

pub const ID: Address = Address::new_from_array([3u8; 32]);
}

Account Discriminators

Define discriminators for your program’s account types:

#![allow(unused)]
fn main() {
const PROPOSAL: u8 = 1;
const VOTE_RECORD: u8 = 2;
}

Proposal Account

The proposal stores the authority, proposal ID, references to the encrypted tally ciphertexts, voting status, and fields for decryption verification:

#![allow(unused)]
fn main() {
#[repr(C)]
pub struct Proposal {
    pub discriminator: u8,
    pub authority: [u8; 32],
    pub proposal_id: [u8; 32],
    pub yes_count: EUint64,              // ciphertext account pubkey
    pub no_count: EUint64,               // ciphertext account pubkey
    pub is_open: u8,
    pub total_votes: [u8; 8],            // plaintext total for transparency
    pub revealed_yes: [u8; 8],           // written after decryption
    pub revealed_no: [u8; 8],            // written after decryption
    pub pending_yes_digest: [u8; 32],    // stored at request_decryption time
    pub pending_no_digest: [u8; 32],     // stored at request_decryption time
    pub bump: u8,
}
}

The yes_count and no_count fields store the pubkeys of the ciphertext accounts. Since EUint64 is a 32-byte type alias, this works naturally – the ciphertext account’s Solana pubkey IS the ciphertext identifier.

The pending_*_digest fields are critical for the store-and-verify pattern. When requesting decryption, request_decryption returns the current ciphertext_digest. You store it here and verify it at reveal time to ensure the ciphertext was not modified between request and response.

#![allow(unused)]
fn main() {
impl Proposal {
    pub const LEN: usize = core::mem::size_of::<Self>();

    pub fn from_bytes(data: &[u8]) -> Result<&Self, ProgramError> {
        if data.len() < Self::LEN || data[0] != PROPOSAL {
            return Err(ProgramError::InvalidAccountData);
        }
        Ok(unsafe { &*(data.as_ptr() as *const Self) })
    }

    pub fn from_bytes_mut(data: &mut [u8]) -> Result<&mut Self, ProgramError> {
        if data.len() < Self::LEN {
            return Err(ProgramError::InvalidAccountData);
        }
        Ok(unsafe { &mut *(data.as_mut_ptr() as *mut Self) })
    }

    pub fn total_votes(&self) -> u64 {
        u64::from_le_bytes(self.total_votes)
    }

    pub fn set_total_votes(&mut self, val: u64) {
        self.total_votes = val.to_le_bytes();
    }
}
}

VoteRecord Account

The vote record is a PDA seeded by ["vote", proposal_id, voter]. Its existence proves the voter already voted. It contains no vote data – the vote is only in the encrypted tally.

#![allow(unused)]
fn main() {
#[repr(C)]
pub struct VoteRecord {
    pub discriminator: u8,
    pub voter: [u8; 32],
    pub bump: u8,
}

impl VoteRecord {
    pub const LEN: usize = core::mem::size_of::<Self>();
}
}

Instruction Dispatch

#![allow(unused)]
fn main() {
fn process_instruction(
    program_id: &Address,
    accounts: &[AccountView],
    data: &[u8],
) -> ProgramResult {
    match data.split_first() {
        Some((&0, rest)) => create_proposal(program_id, accounts, rest),
        Some((&1, rest)) => cast_vote(program_id, accounts, rest),
        Some((&2, _rest)) => close_proposal(accounts),
        Some((&3, rest)) => request_tally_decryption(accounts, rest),
        Some((&4, rest)) => reveal_tally(accounts, rest),
        _ => Err(ProgramError::InvalidInstructionData),
    }
}
}

Next Step

With the program skeleton in place, the next chapter writes the FHE computation logic.

Write FHE Logic

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

The core of confidential voting is a single FHE function that conditionally increments the yes or no counter based on an encrypted vote.

The cast_vote_graph Function

#![allow(unused)]
fn main() {
use encrypt_dsl::prelude::encrypt_fn;
use encrypt_types::encrypted::{EBool, EUint64};

#[encrypt_fn]
fn cast_vote_graph(
    yes_count: EUint64,
    no_count: EUint64,
    vote: EBool,
) -> (EUint64, EUint64) {
    let new_yes = if vote { yes_count + 1 } else { yes_count };
    let new_no = if vote { no_count } else { no_count + 1 };
    (new_yes, new_no)
}
}

What the Macro Generates

The #[encrypt_fn] macro generates two things:

1. Graph bytes function

#![allow(unused)]
fn main() {
fn cast_vote_graph() -> Vec<u8>
}

Returns the serialized computation graph. The graph has:

  • 3 inputs: yes_count (EUint64), no_count (EUint64), vote (EBool)
  • 1 constant: the literal 1 (auto-promoted to an encrypted EUint64 constant)
  • Operations: two Add, two Select (from the if/else expressions)
  • 2 outputs: new_yes (EUint64), new_no (EUint64)

2. CPI extension trait

#![allow(unused)]
fn main() {
trait CastVoteGraphCpi: EncryptCpi {
    fn cast_vote_graph(
        &self,
        yes_count: Self::Account<'_>,   // EUint64 input
        no_count: Self::Account<'_>,    // EUint64 input
        vote: Self::Account<'_>,        // EBool input
        __out_0: Self::Account<'_>,     // EUint64 output
        __out_1: Self::Account<'_>,     // EUint64 output
    ) -> Result<(), Self::Error>;
}

impl<T: EncryptCpi> CastVoteGraphCpi for T {}
}

The trait is automatically implemented for all EncryptCpi types, so you call it as a method on EncryptContext.

How if/else Works in FHE

FHE does not support branching – both branches are always evaluated. The if/else syntax compiles to a Select operation:

1. has_funds = IsEqual(vote, 1)     -- condition (already EBool)
2. yes_plus  = Add(yes_count, 1)    -- both branches computed
3. no_plus   = Add(no_count, 1)
4. new_yes   = Select(vote, yes_plus, yes_count)
5. new_no    = Select(vote, no_count, no_plus)

Both yes_count + 1 and yes_count (unchanged) are computed; Select picks one based on the condition. This is secure because the executor never learns which path was “taken.”

The Literal 1

The integer literal 1 in yes_count + 1 is auto-promoted to an encrypted constant in the graph. The constant is stored in the graph’s constants section and deduplicated – both occurrences of + 1 share the same constant node.

Type Safety

The generated CPI method verifies each input account’s fhe_type at runtime before making the CPI call:

  • yes_count must be a Ciphertext with fhe_type == EUint64
  • no_count must be a Ciphertext with fhe_type == EUint64
  • vote must be a Ciphertext with fhe_type == EBool

If any type mismatches, the transaction fails before the CPI is invoked.

Graph Shape

You can verify the graph structure in tests:

#![allow(unused)]
fn main() {
#[test]
fn graph_shape() {
    let d = cast_vote_graph();
    let pg = parse_graph(&d).unwrap();
    assert_eq!(pg.header().num_inputs(), 3, "yes_count + no_count + vote");
    assert_eq!(pg.header().num_outputs(), 2, "new_yes + new_no");
}
}

Next Step

With the FHE logic defined, the next chapter implements proposal creation and encrypted-zero initialization.

Create Proposal

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

The create_proposal instruction creates the proposal PDA and initializes two ciphertext accounts to encrypted zero.

Instruction Layout

discriminator: 0
data: proposal_bump(1) | cpi_authority_bump(1) | proposal_id(32)
accounts: [proposal_pda(w), authority(s),
           yes_ct(w), no_ct(w),
           encrypt_program, config, deposit(w), cpi_authority,
           caller_program, network_encryption_key, payer(s,w),
           event_authority, system_program]

Implementation

Create the Proposal PDA

#![allow(unused)]
fn main() {
fn create_proposal(
    program_id: &Address,
    accounts: &[AccountView],
    data: &[u8],
) -> ProgramResult {
    let [proposal_acct, authority, yes_ct, no_ct, encrypt_program, config,
         deposit, cpi_authority, caller_program, network_encryption_key,
         payer, event_authority, system_program, ..] = accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };
    if !authority.is_signer() || !payer.is_signer() {
        return Err(ProgramError::MissingRequiredSignature);
    }

    let proposal_bump = data[0];
    let cpi_authority_bump = data[1];
    let proposal_id: [u8; 32] = data[2..34].try_into().unwrap();

    // Create proposal PDA
    let bump_byte = [proposal_bump];
    let seeds = [
        Seed::from(b"proposal" as &[u8]),
        Seed::from(proposal_id.as_ref()),
        Seed::from(&bump_byte),
    ];
    let signer = [Signer::from(&seeds)];

    CreateAccount {
        from: payer,
        to: proposal_acct,
        lamports: minimum_balance(Proposal::LEN),
        space: Proposal::LEN as u64,
        owner: program_id,
    }
    .invoke_signed(&signer)?;
}

Create Encrypted Zeros

This is where Encrypt comes in. Create two ciphertext accounts initialized to encrypted zero using create_plaintext_typed:

#![allow(unused)]
fn main() {
    let ctx = EncryptContext {
        encrypt_program,
        config,
        deposit,
        cpi_authority,
        caller_program,
        network_encryption_key,
        payer,
        event_authority,
        system_program,
        cpi_authority_bump,
    };

    ctx.create_plaintext_typed::<Uint64>(&0u64, yes_ct)?;
    ctx.create_plaintext_typed::<Uint64>(&0u64, no_ct)?;
}

create_plaintext_typed::<Uint64> is a type-safe helper that:

  1. Serializes the value (0u64) as little-endian bytes
  2. Calls create_plaintext_ciphertext with fhe_type = EUint64
  3. Creates a Ciphertext account with status = PENDING and authorized set to the calling program

The executor detects the CiphertextCreated event, encrypts the plaintext value off-chain, and calls commit_ciphertext to write the digest and set status = VERIFIED.

Write Proposal State

#![allow(unused)]
fn main() {
    let d = unsafe { proposal_acct.borrow_unchecked_mut() };
    let prop = Proposal::from_bytes_mut(d)?;
    prop.discriminator = PROPOSAL;
    prop.authority.copy_from_slice(authority.address().as_ref());
    prop.proposal_id.copy_from_slice(&proposal_id);
    prop.yes_count = EUint64::from_le_bytes(*yes_ct.address().as_array());
    prop.no_count = EUint64::from_le_bytes(*no_ct.address().as_array());
    prop.is_open = 1;
    prop.set_total_votes(0);
    prop.bump = proposal_bump;
    Ok(())
}
}

The ciphertext account pubkeys are stored in the proposal so that later instructions can verify the correct accounts are passed.

EncryptContext Fields

Every CPI to the Encrypt program requires an EncryptContext. Here is what each field is:

FieldDescription
encrypt_programThe Encrypt program account
configEncryptConfig PDA (fee schedule, epoch)
depositEncryptDeposit PDA for fee payment
cpi_authorityPDA derived from ["__encrypt_cpi_authority", caller_program_id]
caller_programYour program’s account (the executable that invokes CPI)
network_encryption_keyNetworkEncryptionKey PDA (the FHE public key)
payerSigner who pays for new account rent
event_authorityEncrypt program’s event authority PDA
system_programSystem program
cpi_authority_bumpPDA bump for the CPI authority

Next Step

With the proposal and encrypted tallies created, the next chapter implements vote casting.

Cast Votes

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

The cast_vote instruction is where FHE computation happens. The voter’s encrypted vote is combined with the current tallies via the cast_vote_graph function, and the tally ciphertext accounts are updated in-place.

Instruction Layout

discriminator: 1
data: vote_record_bump(1) | cpi_authority_bump(1)
accounts: [proposal(w), vote_record_pda(w), voter(s), vote_ct,
           yes_ct(w), no_ct(w),
           encrypt_program, config, deposit(w), cpi_authority,
           caller_program, network_encryption_key, payer(s,w),
           event_authority, system_program]

Implementation

Parse and Validate

#![allow(unused)]
fn main() {
fn cast_vote(program_id: &Address, accounts: &[AccountView], data: &[u8]) -> ProgramResult {
    let [proposal_acct, vote_record_acct, voter, vote_ct, yes_ct, no_ct,
         encrypt_program, config, deposit, cpi_authority, caller_program,
         network_encryption_key, payer, event_authority, system_program, ..] = accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };
    if !voter.is_signer() {
        return Err(ProgramError::MissingRequiredSignature);
    }

    let vote_record_bump = data[0];
    let cpi_authority_bump = data[1];

    // Verify proposal is open
    let prop_data = unsafe { proposal_acct.borrow_unchecked() };
    let prop = Proposal::from_bytes(prop_data)?;
    if prop.is_open == 0 {
        return Err(ProgramError::InvalidArgument);
    }
    let proposal_id = prop.proposal_id;
}

Prevent Double Voting

Create a VoteRecord PDA. If the voter already voted, CreateAccount fails because the PDA already exists:

#![allow(unused)]
fn main() {
    let vr_bump_byte = [vote_record_bump];
    let vr_seeds = [
        Seed::from(b"vote" as &[u8]),
        Seed::from(proposal_id.as_ref()),
        Seed::from(voter.address().as_ref()),
        Seed::from(&vr_bump_byte),
    ];
    let vr_signer = [Signer::from(&vr_seeds)];

    CreateAccount {
        from: payer,
        to: vote_record_acct,
        lamports: minimum_balance(VoteRecord::LEN),
        space: VoteRecord::LEN as u64,
        owner: program_id,
    }
    .invoke_signed(&vr_signer)?;

    let vr_data = unsafe { vote_record_acct.borrow_unchecked_mut() };
    vr_data[0] = VOTE_RECORD;
    vr_data[1..33].copy_from_slice(voter.address().as_ref());
    vr_data[33] = vote_record_bump;
}

Execute the FHE Graph

This is the key line – call the DSL-generated CPI method:

#![allow(unused)]
fn main() {
    let ctx = EncryptContext {
        encrypt_program,
        config,
        deposit,
        cpi_authority,
        caller_program,
        network_encryption_key,
        payer,
        event_authority,
        system_program,
        cpi_authority_bump,
    };

    ctx.cast_vote_graph(yes_ct, no_ct, vote_ct, yes_ct, no_ct)?;
}

Notice: yes_ct and no_ct appear as both inputs (positions 1-2) and outputs (positions 4-5). This is update mode.

Update Mode

When an output account already contains ciphertext data, execute_graph operates in update mode:

  • The existing ciphertext is read as an input
  • The same account is reset as an output (digest zeroed, status set to PENDING)
  • The executor evaluates the graph and commits the new digest

This means the tally accounts keep the same pubkey across all votes. No new accounts are created per vote.

Increment Total Votes

After the FHE computation, increment the plaintext vote counter for transparency:

#![allow(unused)]
fn main() {
    let prop_data_mut = unsafe { proposal_acct.borrow_unchecked_mut() };
    let prop_mut = Proposal::from_bytes_mut(prop_data_mut)?;
    prop_mut.set_total_votes(prop_mut.total_votes() + 1);

    Ok(())
}
}

The Voter’s Vote Ciphertext

The vote_ct account is an encrypted boolean (EBool) created by the voter before calling cast_vote. The voter:

  1. Generates a keypair for the ciphertext account
  2. Encrypts their vote (1 = yes, 0 = no) off-chain
  3. Submits it to the executor via create_input_ciphertext (with ZK proof that the value is 0 or 1)
  4. The executor verifies the proof and creates the on-chain ciphertext

The vote value is never visible on-chain. The program only sees the ciphertext account pubkey.

Anchor Equivalent

In Anchor, the same logic uses to_account_info() and .clone():

#![allow(unused)]
fn main() {
let yes_ct = ctx.accounts.yes_ct.to_account_info();
let no_ct = ctx.accounts.no_ct.to_account_info();
let vote_ct = ctx.accounts.vote_ct.to_account_info();
encrypt_ctx.cast_vote_graph(
    yes_ct.clone(), no_ct.clone(), vote_ct,
    yes_ct, no_ct,
)?;
}

Next Step

With voting implemented, the next chapter covers decryption of the final tallies.

Decrypt Results

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

After the proposal is closed, the authority requests decryption of the tally ciphertexts, then reads and verifies the results.

Close the Proposal

First, the authority closes voting:

#![allow(unused)]
fn main() {
fn close_proposal(accounts: &[AccountView]) -> ProgramResult {
    let [proposal_acct, authority, ..] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };
    if !authority.is_signer() {
        return Err(ProgramError::MissingRequiredSignature);
    }

    let prop_data = unsafe { proposal_acct.borrow_unchecked_mut() };
    let prop = Proposal::from_bytes_mut(prop_data)?;

    if authority.address().as_array() != &prop.authority {
        return Err(ProgramError::InvalidArgument);
    }
    if prop.is_open == 0 {
        return Err(ProgramError::InvalidArgument);
    }

    prop.is_open = 0;
    Ok(())
}
}

Request Decryption

The authority calls request_tally_decryption for each tally (yes and no separately):

#![allow(unused)]
fn main() {
fn request_tally_decryption(accounts: &[AccountView], data: &[u8]) -> ProgramResult {
    let [proposal_acct, request_acct, ciphertext, encrypt_program, config,
         deposit, cpi_authority, caller_program, network_encryption_key,
         payer, event_authority, system_program, ..] = accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    let cpi_authority_bump = data[0];
    let is_yes = data[1] != 0;

    // Verify proposal is closed
    let prop_data = unsafe { proposal_acct.borrow_unchecked() };
    let prop = Proposal::from_bytes(prop_data)?;
    if prop.is_open != 0 {
        return Err(ProgramError::InvalidArgument);
    }

    let ctx = EncryptContext {
        encrypt_program, config, deposit, cpi_authority, caller_program,
        network_encryption_key, payer, event_authority, system_program,
        cpi_authority_bump,
    };

    // request_decryption returns the ciphertext_digest -- store it
    let digest = ctx.request_decryption(request_acct, ciphertext)?;

    let prop_data_mut = unsafe { proposal_acct.borrow_unchecked_mut() };
    let prop_mut = Proposal::from_bytes_mut(prop_data_mut)?;
    if is_yes {
        prop_mut.pending_yes_digest = digest;
    } else {
        prop_mut.pending_no_digest = digest;
    }

    Ok(())
}
}

What request_decryption Does

  1. Creates a DecryptionRequest keypair account
  2. Stores a snapshot of the ciphertext’s current ciphertext_digest
  3. Returns the digest as [u8; 32]
  4. Emits a DecryptionRequested event

The decryptor detects the event, performs threshold MPC decryption (or mock decryption locally), and calls respond_decryption to write the plaintext result into the request account.

Why Store the Digest?

The ciphertext could be updated between request and response (e.g., another vote sneaks in). By storing the digest at request time and verifying it at reveal time, you ensure the decrypted value corresponds to the exact ciphertext you requested.

Reveal the Tally

Once the decryptor has responded, the authority reads the result:

#![allow(unused)]
fn main() {
fn reveal_tally(accounts: &[AccountView], data: &[u8]) -> ProgramResult {
    let [proposal_acct, request_acct, authority, ..] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };
    if !authority.is_signer() {
        return Err(ProgramError::MissingRequiredSignature);
    }

    let is_yes = data[0] != 0;

    // Verify authority and closed status
    let prop_data = unsafe { proposal_acct.borrow_unchecked() };
    let prop = Proposal::from_bytes(prop_data)?;
    if authority.address().as_array() != &prop.authority {
        return Err(ProgramError::InvalidArgument);
    }
    if prop.is_open != 0 {
        return Err(ProgramError::InvalidArgument);
    }

    // Get the digest stored at request time
    let expected_digest = if is_yes {
        &prop.pending_yes_digest
    } else {
        &prop.pending_no_digest
    };

    // Verify and read the decrypted value
    let req_data = unsafe { request_acct.borrow_unchecked() };
    let value: &u64 = accounts::read_decrypted_verified::<Uint64>(req_data, expected_digest)?;

    // Write plaintext to proposal
    let prop_data_mut = unsafe { proposal_acct.borrow_unchecked_mut() };
    let prop_mut = Proposal::from_bytes_mut(prop_data_mut)?;
    if is_yes {
        prop_mut.revealed_yes = value.to_le_bytes();
    } else {
        prop_mut.revealed_no = value.to_le_bytes();
    }

    Ok(())
}
}

read_decrypted_verified

This function:

  1. Reads the DecryptionRequestHeader from the request account
  2. Verifies bytes_written == total_len (decryption is complete)
  3. Verifies the stored ciphertext_digest matches expected_digest
  4. Returns a reference to the plaintext value

If the digest does not match, it returns an error – protecting against stale or tampered values.

Full Decryption Flow

1. close_proposal         -- authority closes voting
2. request_tally_decryption(is_yes=true)   -- store yes digest
3. request_tally_decryption(is_yes=false)  -- store no digest
4. [decryptor responds automatically]
5. reveal_tally(is_yes=true)    -- read yes result, verify digest
6. reveal_tally(is_yes=false)   -- read no result, verify digest

After step 6, the proposal’s revealed_yes and revealed_no fields contain the plaintext tallies, readable by anyone.

Cleanup

After revealing, close the decryption request accounts to reclaim rent:

#![allow(unused)]
fn main() {
ctx.close_decryption_request(request_acct, destination)?;
}

Next Step

The next chapter covers testing the complete voting flow with EncryptTestContext.

Testing

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Encrypt provides four levels of testing for your programs:

  1. Unit tests — verify graph logic with mock arithmetic (no SBF needed)
  2. LiteSVM e2e tests — fast in-process lifecycle with deployed programs and CPI
  3. solana-program-test e2e tests — official Solana runtime, full sysvar support
  4. Mollusk tests — isolated instruction-level validation

Setup

[dev-dependencies]
encrypt-solana-test = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-types = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-dsl = { package = "encrypt-solana-dsl", git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
solana-sdk = "4"
mollusk-svm = "0.11"
solana-account = "3"
solana-pubkey = "4"
solana-instruction = "3"

Unit Testing the Graph

The simplest tests verify graph correctness with mock plaintext arithmetic:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::cast_vote_graph;

    #[test]
    fn vote_yes_increments_yes_count() {
        let r = run_mock(
            cast_vote_graph,
            &[10, 5, 1],
            &[FheType::EUint64, FheType::EUint64, FheType::EBool],
        );
        assert_eq!(r[0], 11);
        assert_eq!(r[1], 5);
    }

    #[test]
    fn graph_shape() {
        let d = cast_vote_graph();
        let pg = parse_graph(&d).unwrap();
        assert_eq!(pg.header().num_inputs(), 3);
        assert_eq!(pg.header().num_outputs(), 2);
    }
}
}

Run with cargo test -p your-program --lib — no SBF build needed.

LiteSVM End-to-End Tests

Test the full lifecycle: deploy your program → send transactions → CPI to Encrypt → verify results.

#![allow(unused)]
fn main() {
use encrypt_dsl::prelude::encrypt_fn;
use encrypt_solana_test::litesvm::EncryptTestContext;
use encrypt_types::encrypted::{EBool, EUint64, Bool, Uint64};

// Redefine graph for off-chain evaluation
#[encrypt_fn]
fn cast_vote_graph(yes_count: EUint64, no_count: EUint64, vote: EBool) -> (EUint64, EUint64) {
    let new_yes = if vote { yes_count + 1 } else { yes_count };
    let new_no = if vote { no_count } else { no_count + 1 };
    (new_yes, new_no)
}

#[test]
fn test_full_voting_lifecycle() {
    let mut ctx = EncryptTestContext::new_default();

    // Deploy your program
    let program_id = ctx.deploy_program("path/to/your_program.so");
    let (cpi_authority, cpi_bump) = ctx.cpi_authority_for(&program_id);

    // 1. Create proposal (CPI creates yes/no ciphertexts)
    ctx.send_transaction(&[create_proposal_ix(...)], &[&authority, &yes_ct, &no_ct]);
    ctx.register_ciphertext(&yes_pubkey);
    ctx.register_ciphertext(&no_pubkey);

    // 2. Cast vote (CPI to execute_graph)
    let vote_ct = ctx.create_input::<Bool>(1, &program_id);
    ctx.send_transaction(&[cast_vote_ix(...)], &[&voter]);

    // 3. Process the graph execution off-chain
    let graph = cast_vote_graph();
    ctx.enqueue_graph_execution(&graph, &[yes_pubkey, no_pubkey, vote_ct], &[yes_pubkey, no_pubkey]);
    ctx.process_pending();
    ctx.register_ciphertext(&yes_pubkey);
    ctx.register_ciphertext(&no_pubkey);

    // 4. Close proposal
    ctx.send_transaction(&[close_ix(...)], &[&authority]);

    // 5. Verify results
    assert_eq!(ctx.decrypt_from_store(&yes_pubkey), 1);
    assert_eq!(ctx.decrypt_from_store(&no_pubkey), 0);
}
}

Key patterns for CPI e2e tests

  • register_ciphertext — call after CPI creates/updates ciphertexts the harness doesn’t know about
  • enqueue_graph_execution + process_pending — simulate the off-chain executor evaluating graphs triggered by CPI
  • decrypt_from_store — read results from the mock store (no on-chain decryption request needed)
  • Ciphertext authorization — authorize to the program ID (not the voter), since the program is the CPI caller

Mollusk Instruction Tests

Test individual instructions in isolation without CPI. Best for:

  • Signer/authority checks
  • Account validation
  • Edge cases (already closed, wrong digest, missing accounts)
#![allow(unused)]
fn main() {
use mollusk_svm::Mollusk;

#[test]
fn test_close_proposal_rejects_wrong_authority() {
    let (mollusk, program_id) = setup();
    let auth = Pubkey::new_unique();
    let wrong = Pubkey::new_unique();

    let prop_data = build_proposal_data(&auth, &proposal_id, true, 0);

    let result = mollusk.process_instruction(
        &Instruction::new_with_bytes(program_id, &[2u8], vec![
            AccountMeta::new(prop_key, false),
            AccountMeta::new_readonly(wrong, true),
        ]),
        &[(prop_key, program_account(&program_id, prop_data)), (wrong, funded_account())],
    );
    assert!(result.program_result.is_err());
}

#[test]
fn test_reveal_tally_rejects_digest_mismatch() {
    let (mollusk, program_id) = setup();
    // ... build proposal with digest A, request with digest B
    // ... verify the reveal fails
}
}

solana-program-test

Same API as LiteSVM but uses the official Solana runtime. Programs must be declared upfront:

#![allow(unused)]
fn main() {
use encrypt_solana_test::program_test::ProgramTestEncryptContext;

#[test]
fn test_with_official_runtime() {
    let mut ctx = ProgramTestEncryptContext::builder()
        .add_program("my_program", program_id)
        .build();
    // Same API as EncryptTestContext
}
}

ProgramTestEncryptContext wraps EncryptTestHarness<ProgramTestRuntime>. The ProgramTestRuntime blocks async BanksClient calls on a tokio runtime, so tests remain synchronous.

When to use which:

  • LiteSVM — fastest, good for iteration. Partial sysvar support.
  • solana-program-test — slower, but uses the real Solana runtime. Full sysvar + rent support. Use for CI or when LiteSVM behavior diverges.

Running Tests

# All tests (builds SBF first)
just test

# Unit tests only (fast, no SBF)
just test-unit

# Example tests only
just test-examples               # All (unit + litesvm + mollusk + program-test)
just test-examples-litesvm       # LiteSVM e2e only
just test-examples-mollusk       # Mollusk only
just test-examples-program-test  # solana-program-test e2e only

# Single example
cargo test -p confidential-voting-pinocchio

Mock vs Real FHE

In test mode, EncryptTestContext uses MockComputeEngine — operations are performed as plaintext arithmetic. The 32-byte ciphertext digest directly encodes the plaintext value. This means:

  • Graph evaluation is instantaneous
  • Decryption is trivial
  • No privacy (values visible in account data)

The same test code will work unchanged when real REFHE is available. See Mock vs Real FHE for details.

The Encrypt DSL

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

The #[encrypt_fn] attribute macro lets you write FHE computation as normal Rust. The macro compiles it into a computation graph at compile time.

Two Macros

MacroCrateGenerates
#[encrypt_fn_graph]encrypt-dslGraph bytes function only (fn name() -> Vec<u8>)
#[encrypt_fn]encrypt-solana-dslGraph bytes + Solana CPI extension trait

Use #[encrypt_fn] for Solana programs. Use #[encrypt_fn_graph] for chain-agnostic graph generation (testing, analysis).

What Gets Generated

#![allow(unused)]
fn main() {
#[encrypt_fn]
fn transfer(from: EUint64, to: EUint64, amount: EUint64) -> (EUint64, EUint64) {
    let has_funds = from >= amount;
    let new_from = if has_funds { from - amount } else { from };
    let new_to = if has_funds { to + amount } else { to };
    (new_from, new_to)
}
}

This generates:

  1. transfer()Vec<u8> — the serialized computation graph
  2. TransferCpi — an extension trait implemented for all EncryptCpi types:
#![allow(unused)]
fn main() {
// Generated (simplified):
trait TransferCpi: EncryptCpi {
    fn transfer(
        &self,
        from: Self::Account<'_>,     // EUint64 input
        to: Self::Account<'_>,       // EUint64 input
        amount: Self::Account<'_>,   // EUint64 input
        __out_0: Self::Account<'_>,  // EUint64 output
        __out_1: Self::Account<'_>,  // EUint64 output
    ) -> Result<(), Self::Error>;
}

impl<T: EncryptCpi> TransferCpi for T {}
}

Method Syntax

Call the generated function as a method on your EncryptContext:

#![allow(unused)]
fn main() {
ctx.transfer(from_ct, to_ct, amount_ct, new_from_ct, new_to_ct)?;
}

The trait is automatically in scope (generated in the same module as your #[encrypt_fn]).

Type Safety

The generated function:

  • Has one parameter per encrypted input (in order)
  • Has one parameter per output (in order)
  • Verifies each input’s fhe_type matches the graph at runtime
  • Returns an error if types don’t match

This catches bugs like passing an EBool where an EUint64 is expected.

Update Mode

Output accounts can be either:

  • New accounts (empty) → execute_graph creates a new Ciphertext
  • Existing accounts (already has data) → execute_graph resets digest/status (reuses the account)

For update mode, pass the same account as both input and output:

#![allow(unused)]
fn main() {
// yes_ct is both input[0] and output[0]
ctx.cast_vote_graph(yes_ct, no_ct, vote_ct, yes_ct, no_ct)?;
}

FHE Types

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Scalar Types (16)

TypeByte WidthRust Equivalent
EBool1u8 (0 or 1)
EUint81u8
EUint162u16
EUint324u32
EUint648u64
EUint12816u128
EUint25632[u8; 32]
EAddress32[u8; 32]
EUint51264[u8; 64]
EUint1024128[u8; 128]
… up to EUint655368192[u8; 8192]

Boolean Vectors (16)

EBitVector2 through EBitVector65536 — packed boolean arrays.

Arithmetic Vectors (13)

EVectorU8 through EVectorU32768 — SIMD-style encrypted integer arrays (8,192 bytes each).

Plaintext Types

For inputs that don’t need encryption:

TypeEncrypted Equivalent
PBoolEBool
PUint8EUint8
PUint16EUint16
PUint32EUint32
PUint64EUint64

Plaintext inputs are embedded in the instruction data (not ciphertext accounts).

Type Safety

Each type has a compile-time FHE_TYPE_ID:

  • Operations between incompatible types fail at compile time
  • The on-chain processor verifies fhe_type of each input account matches the graph
  • The CPI extension trait verifies fhe_type at runtime before CPI

Operations

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Arithmetic

#![allow(unused)]
fn main() {
let sum = a + b;      // Add
let diff = a - b;     // Subtract
let prod = a * b;     // Multiply
let quot = a / b;     // Divide
let rem = a % b;      // Modulo
let neg = -a;         // Negate
}

Bitwise

#![allow(unused)]
fn main() {
let and = a & b;      // AND
let or = a | b;       // OR
let xor = a ^ b;      // XOR
let not = !a;         // NOT
let shl = a << b;     // Shift left
let shr = a >> b;     // Shift right
}

Comparison

All comparisons return the same encrypted type (0 or 1), not EBool:

#![allow(unused)]
fn main() {
let eq = a == b;      // Equal
let ne = a != b;      // Not equal
let lt = a < b;       // Less than
let le = a <= b;      // Less or equal
let gt = a > b;       // Greater than
let ge = a >= b;      // Greater or equal
}

Method Syntax

Same operations, explicit names:

#![allow(unused)]
fn main() {
let sum = a.add(&b);
let cmp = a.is_greater_or_equal(&b);
let min_val = a.min(&b);
let max_val = a.max(&b);
let rotated = a.rotate_left(&n);
}

Constants

Bare integer literals are auto-promoted to encrypted constants:

#![allow(unused)]
fn main() {
let incremented = count + 1;       // 1 becomes an encrypted constant
let doubled = value * 2;           // 2 becomes an encrypted constant
}

For explicit construction:

#![allow(unused)]
fn main() {
let one = EUint64::from(1u64);
let big = EUint256::from([0xABu8; 32]);
let vec = EVectorU32::from_elements([1u32, 2, 3, 4]);
let ones = EVectorU64::splat(1u128);
let bits = EBitVector16::from(0b1010u128);
}

Identical constants are automatically deduplicated in the graph.

Constants

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Constants are plaintext values embedded directly in the computation graph. The executor applies encryption automatically.

Bare Literals

The simplest way — integer literals in expressions auto-promote:

#![allow(unused)]
fn main() {
#[encrypt_fn]
fn increment(count: EUint64) -> EUint64 {
    count + 1  // 1 is auto-promoted to an encrypted EUint64 constant
}
}

Explicit Construction

For types that need explicit creation:

#![allow(unused)]
fn main() {
// Scalars (up to 128 bits)
let zero = EUint64::from(0u64);
let max = EUint128::from(u128::MAX);

// Big types (byte arrays)
let addr = EUint256::from([0xABu8; 32]);

// Vectors — from elements
let vec = EVectorU32::from_elements([1u32, 2, 3, 4]);

// Vectors — all same value
let ones = EVectorU64::splat(1u128);

// Boolean vectors — from bitmask
let mask = EBitVector16::from(0b1010_1010u128);
}

Deduplication

Constants with the same (fhe_type, bytes) are automatically deduplicated in the graph. Writing count + 1 twice produces a single constant node, not two.

Conditionals

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

FHE doesn’t support branching — both paths are always evaluated. The if/else syntax compiles to a select operation.

Syntax

#![allow(unused)]
fn main() {
let result = if condition { value_a } else { value_b };
}

Rules:

  • Both branches must be the same encrypted type
  • Condition must be an encrypted comparison result (0 or 1)
  • else is mandatory — no bare if
  • Both branches are always evaluated (FHE requirement)

Example

#![allow(unused)]
fn main() {
#[encrypt_fn]
fn conditional_transfer(
    from: EUint64,
    to: EUint64,
    amount: EUint64,
) -> (EUint64, EUint64) {
    let has_funds = from >= amount;
    let new_from = if has_funds { from - amount } else { from };
    let new_to = if has_funds { to + amount } else { to };
    (new_from, new_to)
}
}

This compiles to:

  1. has_funds = IsGreaterOrEqual(from, amount) → 0 or 1
  2. from_minus = Subtract(from, amount)
  3. to_plus = Add(to, amount)
  4. new_from = Select(has_funds, from_minus, from)
  5. new_to = Select(has_funds, to_plus, to)

Both from - amount and from are computed; Select picks one based on the condition.

Nested Conditionals

#![allow(unused)]
fn main() {
let tier = if amount >= 1000 {
    3
} else if amount >= 100 {
    2
} else {
    1
};
}

Each if/else becomes a Select operation. Nested conditionals produce a chain of Select nodes.

Graph Compilation

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Binary Format

The #[encrypt_fn] macro compiles your function into a binary graph at compile time:

[Header 13B] [Nodes N×9B] [Constants section]

Header (13 bytes)

version(1) | num_inputs(2) | num_plaintext_inputs(2) | num_constants(2) | num_ops(2) | num_outputs(2) | constants_len(2)

Counts are ordered by node kind. num_nodes is derived (sum of all counts).

Nodes (9 bytes each)

kind(1) | op_type(1) | fhe_type(1) | input_a(2) | input_b(2) | input_c(2)
KindValueDescription
Input0Encrypted ciphertext account
PlaintextInput1Plaintext value in instruction data
Constant2Literal value in constants section
Op3FHE operation
Output4Graph result

Nodes are topologically sorted — every node’s operands appear earlier in the list.

Constants Section

Variable-length byte blob. Constant nodes reference it by byte offset (input_a). Values stored as little-endian bytes at fhe_type.byte_width().

Example

#![allow(unused)]
fn main() {
#[encrypt_fn]
fn add(a: EUint64, b: EUint64) -> EUint64 { a + b }
}

Produces 4 nodes:

  • Node 0: Input (EUint64) — a
  • Node 1: Input (EUint64) — b
  • Node 2: Op (Add, EUint64, inputs: 0, 1)
  • Node 3: Output (EUint64, source: 2)

Header: version=1, num_inputs=2, num_constants=0, num_ops=1, num_outputs=1, constants_len=0

Registered Graphs

For frequently used graphs, register them on-chain to avoid re-sending graph data:

#![allow(unused)]
fn main() {
ctx.register_graph(graph_pda, bump, &graph_hash, &graph_data)?;
ctx.execute_registered_graph(graph_pda, ix_data, remaining)?;
}

Registered graphs enable exact per-op fee calculation (no max-charge gap).

Ciphertext Accounts

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Structure

Ciphertext accounts are regular keypair accounts (not PDAs). The Encrypt program is the Solana owner.

FieldSizeDescription
ciphertext_digest32Hash of the encrypted blob (zero until committed)
authorized32Who can use this (zero address = public)
network_encryption_public_key32FHE key it was encrypted under
fhe_type1Type discriminant (EBool=0, EUint64=4, etc.)
status1Pending(0) or Verified(1)

Total: 98 bytes data + 2 bytes prefix (discriminator + version) = 100 bytes.

Account Pubkey = Identifier

The account’s Solana pubkey IS the ciphertext identifier. There is no separate ciphertext_id field. This means:

  • Client generates a keypair for each new ciphertext
  • The pubkey is used in events, store lookups, and all references
  • Update mode reuses the same account (same pubkey, new digest)

Creating Ciphertexts

Authority Input (create_input_ciphertext, disc 1)

User encrypts off-chain → submits to executor with ZK proof → executor verifies → calls this instruction. Status = Verified.

Plaintext (create_plaintext_ciphertext, disc 2)

User provides plaintext value directly. Executor encrypts off-chain and commits digest later. Status = Pending until committed.

#![allow(unused)]
fn main() {
ctx.create_plaintext_typed::<Uint64>(&0u64, ciphertext_account)?;
}

Graph Output (execute_graph, disc 4)

Computation outputs are created automatically by execute_graph:

  • New account (empty) → creates Ciphertext with status=Pending
  • Existing account (has data) → resets digest/status (update mode)

Status Lifecycle

Created (by execute_graph) → PENDING → commit_ciphertext → VERIFIED
Created (by create_input)  → VERIFIED (immediately)
Created (by plaintext)     → PENDING → commit_ciphertext → VERIFIED

Access Control

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

The authorized Field

Every ciphertext has an authorized field (32 bytes):

ValueMeaning
[0; 32] (zero)Public — anyone can compute on it and decrypt it
<pubkey>Only that address can use it (wallet signer or program)

There are no separate guard/permission accounts. The ciphertext IS the access token.

Managing Access

Transfer Authorization

Move authorization from current party to a new party:

#![allow(unused)]
fn main() {
// Pinocchio
ctx.transfer_ciphertext(ciphertext, new_authorized)?;

// Anchor
ctx.transfer_ciphertext(&ciphertext.to_account_info(), &new_auth.to_account_info())?;
}

The current authorized party must sign the transaction.

Copy with Different Authorization

Create a copy of the ciphertext authorized to a different party:

#![allow(unused)]
fn main() {
ctx.copy_ciphertext(
    source_ciphertext,
    new_ciphertext,     // empty keypair account
    new_authorized,
    false,              // permanent (rent-exempt)
)?;
}

Set transient: true for copies that only live within the current transaction (0 lamports, GC’d after tx).

Make Public

Set authorized to zero — irreversible, anyone can use it:

#![allow(unused)]
fn main() {
ctx.make_public(ciphertext)?;
}

Idempotent — calling on an already-public ciphertext is a no-op.

CPI Authorization

When a program calls Encrypt via CPI:

  • Signer path: caller is a wallet signer → authorized checked against signer pubkey
  • Program path: caller is executable → next account is CPI authority PDA (__encrypt_cpi_authority) → authorized checked against program address

Detection is automatic via caller.executable().

Execute Graph

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

How It Works

execute_graph (disc 4) processes a computation graph:

  1. Parses the graph binary from instruction data
  2. Verifies each input ciphertext’s fhe_type matches the graph
  3. Verifies each input’s authorized matches the caller
  4. Charges fees (per input + constant + plaintext input + output + operation)
  5. Creates or updates output ciphertext accounts (status=PENDING)
  6. Emits GraphExecutedEvent for the executor

Instruction Data

discriminator(1) | graph_data_len(2) | graph_data(N) | num_inputs(2)

Account Layout

PositionAccountWritableSigner
0confignono
1deposityesno
2callernoyes (signer path)
3network_encryption_keynono
4payeryesyes
5event_authoritynono
6programnono
7..7+Ninput ciphertextsnono
7+N..7+N+Moutput ciphertextsyesno

For CPI path: cpi_authority is inserted at position 3, shifting subsequent accounts.

Update Mode

Output accounts can be existing ciphertexts:

  • If the output account already has data → update mode: resets ciphertext_digest and status to PENDING
  • If the output account is empty → create mode: creates a new Ciphertext

This means the same account can be used as both input and output (e.g., yes_count is read, then updated in the same execute_graph call).

Type Verification

The processor verifies each input ciphertext’s fhe_type matches the graph’s Input node fhe_type. If they don’t match, the transaction fails with InvalidArgument.

Using the DSL

Instead of building instruction data manually, use the generated CPI method:

#![allow(unused)]
fn main() {
// Generated by #[encrypt_fn]:
ctx.cast_vote_graph(yes_ct, no_ct, vote_ct, yes_ct, no_ct)?;
//                   ↑inputs↑              ↑outputs↑
}

Decryption

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Request → Respond → Read

Decryption is an async on-chain request/response pattern:

1. Request Decryption

#![allow(unused)]
fn main() {
let digest = ctx.request_decryption(request_acct, ciphertext)?;
// Store `digest` in your program state for later verification
proposal.pending_digest = digest;
}
  • Creates a DecryptionRequest keypair account
  • Stores a ciphertext_digest snapshot (stale-value protection)
  • Returns the digest — store it for verification at read time
  • The decryptor detects the event and responds

2. Process (Automatic)

The decryptor:

  1. Detects DecryptionRequestedEvent
  2. Performs threshold MPC decryption (or mock decryption locally)
  3. Calls respond_decryption to write plaintext bytes into the request account

3. Read Result

#![allow(unused)]
fn main() {
let req_data = request_acct.try_borrow_data()?;
let value = read_decrypted_verified::<Uint64>(&req_data, &proposal.pending_digest)?;
}

Always verify against the stored digest — if the ciphertext was updated between request and response, the digest won’t match and read_decrypted_verified returns an error.

4. Close Request

After reading the result, reclaim rent:

#![allow(unused)]
fn main() {
ctx.close_decryption_request(request_acct, destination)?;
}

DecryptionRequest Account

FieldSizeDescription
ciphertext32Ciphertext account pubkey
ciphertext_digest32Digest snapshot at request time
requester32Who requested
fhe_type1Type (determines result byte width)
total_len4Expected result size
bytes_written4Progress (0=pending, ==total_len=complete)
result datavariablePlaintext bytes (appended after header)

Total: 2 (prefix) + 105 (header) + byte_width(fhe_type) bytes.

Type-Safe Reading

Use the SDK helpers:

#![allow(unused)]
fn main() {
// Pinocchio
use encrypt_pinocchio::accounts::{read_decrypted_verified, ciphertext_digest};

// Read digest from ciphertext account
let ct_data = ciphertext.borrow_unchecked();
let digest = ciphertext_digest(ct_data)?;

// Verify and read result
let value: &u64 = read_decrypted_verified::<Uint64>(req_data, digest)?;
}

Best Practice: Store-and-Verify

#![allow(unused)]
fn main() {
// At request time:
let digest = ctx.request_decryption(request, ciphertext)?;
state.pending_digest = digest;

// At reveal time:
let value = read_decrypted_verified::<Uint64>(req_data, &state.pending_digest)?;
}

This pattern protects against the ciphertext being updated between request and reveal.

CPI Framework

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

EncryptCpi Trait

All three framework SDKs implement the same trait:

#![allow(unused)]
fn main() {
pub trait EncryptCpi {
    type Error;
    type Account<'a>: Clone where Self: 'a;

    fn invoke_execute_graph<'a>(
        &'a self, ix_data: &[u8], accounts: &[Self::Account<'a>],
    ) -> Result<(), Self::Error>;

    fn read_fhe_type<'a>(&'a self, account: Self::Account<'a>) -> Option<u8>;
    fn type_mismatch_error(&self) -> Self::Error;
}
}

EncryptContext

Each framework provides EncryptContext:

#![allow(unused)]
fn main() {
let ctx = EncryptContext {
    encrypt_program,
    config,
    deposit,
    cpi_authority,
    caller_program,
    network_encryption_key,
    payer,
    event_authority,
    system_program,
    cpi_authority_bump,
};
}

The struct is identical across frameworks — only the account types differ:

  • Pinocchio: &'a AccountView
  • Native: &'a AccountInfo<'info>
  • Anchor: AccountInfo<'info>

Available Methods

MethodDescription
create_plaintext(fhe_type, bytes, ct)Create plaintext ciphertext
create_plaintext_typed::<T>(value, ct)Type-safe plaintext creation
execute_graph(ix_data, remaining)Execute computation graph
execute_registered_graph(graph_pda, ix_data, remaining)Execute registered graph
register_graph(pda, bump, hash, data)Register a reusable graph
transfer_ciphertext(ct, new_authorized)Transfer authorization
copy_ciphertext(source, new_ct, new_auth, transient)Copy with different auth
make_public(ct)Make ciphertext public
request_decryption(request, ct)Request decryption (returns digest)
close_decryption_request(request, destination)Close and reclaim rent

DSL Extension Traits

#[encrypt_fn] generates extension traits that add graph-specific methods:

#![allow(unused)]
fn main() {
// Your DSL function:
#[encrypt_fn]
fn add(a: EUint64, b: EUint64) -> EUint64 { a + b }

// Call as a method on any EncryptContext:
ctx.add(input_a, input_b, output)?;
}

The generated method:

  1. Verifies each input account’s fhe_type at runtime
  2. Builds the execute_graph instruction data
  3. Assembles remaining accounts (inputs then outputs)
  4. Invokes CPI

Pinocchio

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Dependencies

[dependencies]
encrypt-types = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-dsl = { package = "encrypt-solana-dsl", git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-pinocchio = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
pinocchio = "0.10"
pinocchio-system = "0.5"

Setup EncryptContext

#![allow(unused)]
fn main() {
use encrypt_pinocchio::EncryptContext;

let ctx = EncryptContext {
    encrypt_program,
    config,
    deposit,
    cpi_authority,
    caller_program,
    network_encryption_key,
    payer,
    event_authority,
    system_program,
    cpi_authority_bump,
};
}

Create Encrypted Zeros

#![allow(unused)]
fn main() {
use encrypt_types::encrypted::Uint64;

ctx.create_plaintext_typed::<Uint64>(&0u64, ciphertext_acct)?;
}

Execute Graph

#![allow(unused)]
fn main() {
// Via DSL-generated method (preferred)
ctx.cast_vote_graph(yes_ct, no_ct, vote_ct, yes_ct, no_ct)?;

// Via manual execute_graph
ctx.execute_graph(&ix_data, &[yes_ct, no_ct, vote_ct, yes_ct, no_ct])?;
}

Request Decryption

#![allow(unused)]
fn main() {
let digest = ctx.request_decryption(request_acct, ciphertext)?;
// Store digest for later verification
}

Read Decrypted Value

#![allow(unused)]
fn main() {
use encrypt_pinocchio::accounts::{read_decrypted_verified, ciphertext_digest};

let ct_data = unsafe { ciphertext.borrow_unchecked() };
let digest = ciphertext_digest(ct_data)?;
let req_data = unsafe { request_acct.borrow_unchecked() };
let value: &u64 = read_decrypted_verified::<Uint64>(req_data, digest)?;
}

Full Example

See chains/solana/examples/confidential-voting-pinocchio/ for a complete confidential voting program.

Anchor

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Dependencies

[dependencies]
encrypt-types = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-dsl = { package = "encrypt-solana-dsl", git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-anchor = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
anchor-lang = "0.32"

Setup EncryptContext

#![allow(unused)]
fn main() {
use encrypt_anchor::EncryptContext;

let ctx = EncryptContext {
    encrypt_program: ctx.accounts.encrypt_program.to_account_info(),
    config: ctx.accounts.config.to_account_info(),
    deposit: ctx.accounts.deposit.to_account_info(),
    cpi_authority: ctx.accounts.cpi_authority.to_account_info(),
    caller_program: ctx.accounts.caller_program.to_account_info(),
    network_encryption_key: ctx.accounts.network_encryption_key.to_account_info(),
    payer: ctx.accounts.payer.to_account_info(),
    event_authority: ctx.accounts.event_authority.to_account_info(),
    system_program: ctx.accounts.system_program.to_account_info(),
    cpi_authority_bump,
};
}

Execute Graph

#![allow(unused)]
fn main() {
let yes_ct = ctx.accounts.yes_ct.to_account_info();
let no_ct = ctx.accounts.no_ct.to_account_info();
let vote_ct = ctx.accounts.vote_ct.to_account_info();
encrypt_ctx.cast_vote_graph(
    yes_ct.clone(), no_ct.clone(), vote_ct,
    yes_ct, no_ct,
)?;
}

Note: Anchor’s AccountInfo is Clone, so you can pass the same account as both input and output.

Request Decryption

#![allow(unused)]
fn main() {
let digest = encrypt_ctx.request_decryption(
    &ctx.accounts.request_acct.to_account_info(),
    &ctx.accounts.ciphertext.to_account_info(),
)?;
}

Read Decrypted Value

#![allow(unused)]
fn main() {
use encrypt_anchor::accounts::{read_decrypted_verified, ciphertext_digest};

let ct_data = ctx.accounts.ciphertext.try_borrow_data()?;
let digest = ciphertext_digest(&ct_data)?;
let req_data = ctx.accounts.request_acct.try_borrow_data()?;
let value = read_decrypted_verified::<Uint64>(&req_data, digest)?;
}

Account Structs

Include Encrypt accounts in your Anchor #[derive(Accounts)]:

#![allow(unused)]
fn main() {
#[derive(Accounts)]
pub struct CastVote<'info> {
    #[account(mut)]
    pub proposal: Account<'info, Proposal>,
    pub voter: Signer<'info>,
    /// CHECK: Vote ciphertext
    #[account(mut)]
    pub vote_ct: UncheckedAccount<'info>,
    /// CHECK: Yes count ciphertext
    #[account(mut)]
    pub yes_ct: UncheckedAccount<'info>,
    /// CHECK: No count ciphertext
    #[account(mut)]
    pub no_ct: UncheckedAccount<'info>,
    /// CHECK: Encrypt program
    pub encrypt_program: UncheckedAccount<'info>,
    // ... config, deposit, cpi_authority, etc.
}
}

Full Example

See chains/solana/examples/confidential-voting-anchor/ for a complete program.

Native (solana-program)

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Dependencies

[dependencies]
encrypt-types = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-dsl = { package = "encrypt-solana-dsl", git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
encrypt-native = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }
solana-program = "4"

Setup EncryptContext

#![allow(unused)]
fn main() {
use encrypt_native::EncryptContext;

let ctx = EncryptContext {
    encrypt_program,
    config,
    deposit,
    cpi_authority,
    caller_program,
    network_encryption_key,
    payer,
    event_authority,
    system_program,
    cpi_authority_bump,
};
}

Create Encrypted Zeros

#![allow(unused)]
fn main() {
use encrypt_types::encrypted::Uint64;

ctx.create_plaintext_typed::<Uint64>(&0u64, ciphertext_acct)?;
}

Execute Graph

#![allow(unused)]
fn main() {
ctx.cast_vote_graph(
    yes_ct.clone(), no_ct.clone(), vote_ct.clone(),
    yes_ct.clone(), no_ct.clone(),
)?;
}

Note: Native AccountInfo is Clone, so you can clone for duplicate references.

Request Decryption

#![allow(unused)]
fn main() {
let digest = ctx.request_decryption(request_acct, ciphertext)?;
}

Read Decrypted Value

#![allow(unused)]
fn main() {
use encrypt_native::accounts::{read_decrypted_verified, ciphertext_digest};

let ct_data = ciphertext.try_borrow_data()?;
let digest = ciphertext_digest(&ct_data)?;
let req_data = request_acct.try_borrow_data()?;
let value = read_decrypted_verified::<Uint64>(&req_data, digest)?;
}

Full Example

See chains/solana/examples/confidential-voting-native/ for a complete program.

Test Framework

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Overview

encrypt-solana-test provides three testing modes:

  • LiteSVM (EncryptTestContext) — fast in-process e2e tests
  • solana-program-test (ProgramTestEncryptContext) — official Solana runtime e2e tests
  • Mollusk — single-instruction unit tests with pre-built account data
[dev-dependencies]
encrypt-solana-test = { git = "https://github.com/dwallet-labs/encrypt-pre-alpha" }

Architecture

encrypt-dev (chains/solana/dev/) — production-safe, no test deps
  ├── SolanaRuntime                # Production (send_transaction, get_account_data, ...)
  ├── TestRuntime                  # Dev/test (adds airdrop, deploy_program)
  ├── InProcessTestRuntime         # In-process only (adds set_account, advance_slot)
  └── EncryptTxBuilder<R>          # Tx construction for all Encrypt instructions

encrypt-solana-test (chains/solana/test/)
  ├── LiteSvmRuntime               # LiteSVM backend (InProcessTestRuntime)
  ├── ProgramTestRuntime           # solana-program-test backend (InProcessTestRuntime)
  ├── EncryptTestHarness<R>        # Wraps TxBuilder + MockComputeEngine + store + work queue
  ├── EncryptTestContext            # Ergonomic LiteSVM wrapper
  ├── ProgramTestEncryptContext     # Ergonomic solana-program-test wrapper
  └── mollusk helpers               # Account builders, discriminators, setup

encrypt-dev has no test framework dependencies — only the runtime trait hierarchy and EncryptTxBuilder. Test runtimes and harness live in encrypt-solana-test.

EncryptTestContext

#![allow(unused)]
fn main() {
use encrypt_solana_test::litesvm::EncryptTestContext;
use encrypt_types::encrypted::Uint64;

#[test]
fn test_my_program() {
    let mut ctx = EncryptTestContext::new_default();
    let user = ctx.new_funded_keypair();

    let a = ctx.create_input::<Uint64>(10, &user.pubkey());
    let b = ctx.create_input::<Uint64>(32, &user.pubkey());

    let graph = my_add_graph();
    let outputs = ctx.execute_and_commit(&graph, &[a, b], 1, &[], &user);

    let result = ctx.decrypt::<Uint64>(&outputs[0], &user);
    assert_eq!(result, 42);
}
}

How It Works

  1. LiteSVM runs in-process — no external validator needed
  2. A local authority keypair signs commit_ciphertext and respond_decryption
  3. An in-memory CiphertextStore tracks all ciphertext digests
  4. execute_and_commit() calls execute_graph on-chain, then evaluates the graph off-chain using MockComputeEngine and commits results
  5. decrypt() calls request_decryption on-chain, then decrypts and responds

All off-chain processing happens synchronously — no event polling needed.

API Reference

MethodDescription
new(elf_path)Create context with custom program path
new_default()Create with default build output path
new_funded_keypair()Create and fund a new keypair (10 SOL)
create_input::<T>(value, authorized)Create verified encrypted input (authority-driven)
create_plaintext::<T>(value, creator)Create plaintext ciphertext (user-signed)
execute_and_commit(graph, inputs, n_outputs, existing_outputs, caller)Execute + commit in one call
decrypt::<T>(ct_pubkey, requester)Decrypt and return plaintext value
decrypt_from_store(ct_pubkey)Read value from mock store (no on-chain request)
deploy_program(elf_path)Deploy an additional program, returns ID
deploy_program_at(id, elf_path)Deploy at a specific address
cpi_authority_for(caller_program)Derive CPI authority PDA for a program
send_transaction(ixs, signers)Sign and send a transaction
get_account_data(pubkey)Read raw account data
register_ciphertext(pubkey)Register CPI-created ciphertext in the store
enqueue_graph_execution(graph, inputs, outputs)Enqueue CPI-triggered graph for processing
process_pending()Process all queued graph executions and decryptions
program_id() / config_pda() / deposit_pda() / etc.Access Encrypt program PDAs

Testing CPI Programs (e2e)

For programs that call the Encrypt program via CPI (like the voting examples):

#![allow(unused)]
fn main() {
use encrypt_solana_test::litesvm::EncryptTestContext;
use encrypt_types::encrypted::{Bool, Uint64};

#[test]
fn test_voting_lifecycle() {
    let mut ctx = EncryptTestContext::new_default();

    // Deploy your program
    let program_id = ctx.deploy_program("path/to/your_program.so");
    let (cpi_authority, cpi_bump) = ctx.cpi_authority_for(&program_id);

    // Create proposal (CPI creates ciphertexts)
    // ... send create_proposal transaction ...

    // Register CPI-created ciphertexts in the harness store
    ctx.register_ciphertext(&yes_ct_pubkey);
    ctx.register_ciphertext(&no_ct_pubkey);

    // Cast vote (CPI to execute_graph)
    // ... send cast_vote transaction ...

    // Enqueue the graph execution for off-chain processing
    ctx.enqueue_graph_execution(&graph_data, &inputs, &outputs);
    ctx.process_pending();

    // Re-register updated ciphertexts
    ctx.register_ciphertext(&yes_ct_pubkey);
    ctx.register_ciphertext(&no_ct_pubkey);

    // Verify results from the mock store
    let yes = ctx.decrypt_from_store(&yes_ct_pubkey);
    assert_eq!(yes, 1);
}
}

Testing Update Mode

For programs that reuse ciphertext accounts:

#![allow(unused)]
fn main() {
let yes_ct = ctx.create_input::<Uint64>(0, &program_id);
let no_ct = ctx.create_input::<Uint64>(0, &program_id);
let vote = ctx.create_input::<Bool>(1, &program_id);

// Pass yes_ct and no_ct as both inputs and existing outputs (update mode)
let outputs = ctx.execute_and_commit(
    &cast_vote_graph(),
    &[yes_ct, no_ct, vote],
    0,                       // no new outputs
    &[yes_ct, no_ct],        // existing outputs (update mode)
    &caller,
);
}

Mollusk Mode

For single-instruction unit tests:

#![allow(unused)]
fn main() {
use encrypt_solana_test::mollusk::*;

let (mollusk, program_id) = setup();
let ct_data = build_ciphertext_data(&digest, &authorized, &nk, fhe_type, status);

let result = mollusk.process_instruction(
    &Instruction::new_with_bytes(program_id, &ix_data, accounts),
    &[(key, program_account(&program_id, ct_data))],
);
assert!(result.program_result.is_ok());
}

Mollusk is best for testing individual instructions in isolation — signer checks, discriminator validation, authority verification, digest matching, etc.

Mock vs Real FHE

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Mock Mode (Pre-Alpha)

The pre-alpha environment uses mock FHE — operations are performed as plaintext arithmetic with keccak256 digests. This means:

  • add(encrypt(10), encrypt(32))encrypt(42) — correct result, no actual encryption
  • Graph evaluation is instantaneous (no FHE overhead)
  • Decryption is trivial
  • No security — values are not encrypted on-chain

Your program logic, computation graphs, and client code all work identically in mock and real mode. Only the off-chain executor differs.

Real REFHE Mode (Coming Soon)

In production, the executor will use the REFHE library:

  • Actual homomorphic encryption on ciphertext blobs
  • Decryption requires threshold MPC (multiple decryptor nodes)
  • Full privacy — values are never visible on-chain

No code changes required — the same #[encrypt_fn] graphs, CPI calls, and gRPC client calls work in both modes.

Examples

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Complete example programs demonstrating Encrypt on Solana. Each example includes the on-chain program (Anchor), tests, and where applicable a React frontend that runs against the pre-alpha executor on devnet.

All examples connect to the pre-alpha environment automatically:

ResourceEndpoint
Encrypt gRPChttps://pre-alpha-dev-1.encrypt.ika-network.net:443
Solana NetworkDevnet (https://api.devnet.solana.com)

Confidential Counter

An always-encrypted counter. Increment and decrement happen via FHE – the on-chain program never sees the plaintext. Demonstrates the core Encrypt patterns: #[encrypt_fn], CPI via EncryptContext, and the store-and-verify digest pattern for decryption.

Covers: FHE graphs, in-place ciphertext updates, polling for executor completion, React frontend with wallet adapter.

Encrypted Coin Flip

Provably fair coin flip with on-chain escrow. Two sides commit encrypted values, the executor computes XOR via FHE, and the winner receives 2x from escrow. Neither side can see the other’s value before committing.

Covers: XOR-based fairness, escrow pattern, player-vs-house architecture with automated Bun backend, full-stack React app.

Confidential Voting

Encrypted voting where individual votes are hidden but the tally is computed via FHE. Voters cast encrypted yes/no votes (EBool), and the program conditionally increments encrypted counters using a Select operation. Only the authority can reveal final tallies.

Covers: Conditional FHE logic (if/else → Select), multi-output graphs, double-vote prevention via VoteRecord PDA, multi-wallet URL sharing, E2E demos in Rust + TypeScript (web3.js, kit, gill).

Encrypted ACL

An on-chain access control list where permissions are stored as encrypted 64-bit bitmasks. Grant, revoke, and check operations use FHE bitwise operations (OR, AND). Nobody can see what permissions are set.

Covers: Multiple FHE graphs in one program, inverse mask pattern for revocation, separate state accounts with independent decryption flows, admin-gated vs public operations.

Confidential Counter: Overview

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

What We’re Building

A Solana counter whose value is always encrypted. Increment and decrement happen via FHE – the on-chain program never sees the plaintext. Only the owner can request decryption to reveal the current count.

Architecture

User (React app)
  |
  v
Solana Program (Anchor)
  |  CPI
  v
Encrypt Program
  |  emit_event
  v
Executor (off-chain)
  |  FHE computation
  v
Commit result on-chain
  |
  v
Decryptor (threshold MPC)
  |
  v
Plaintext available to owner
  1. The Anchor program stores a Counter PDA with a reference to a ciphertext account.
  2. When you call increment, the program issues a CPI to the Encrypt program with a precompiled FHE graph (value + 1). No computation happens on-chain.
  3. An off-chain executor picks up the event, evaluates the graph using FHE, and commits the result back to the same ciphertext account.
  4. To read the value, the owner calls request_value_decryption. A threshold decryptor network processes the request and writes the plaintext into a decryption request account.
  5. The owner calls reveal_value to copy the verified plaintext into the counter state.

What You’ll Learn

  • Writing FHE graphs with #[encrypt_fn]
  • CPI to the Encrypt program via EncryptContext
  • The store-and-verify digest pattern for decryption
  • Building a React frontend that polls for executor/decryptor completion

Prerequisites

  • Rust (edition 2024, nightly or stable with Solana toolchain)
  • Solana CLI + Platform Tools v1.54
  • Anchor framework
  • Bun (for the React frontend)

The executor and gRPC server are running on the pre-alpha environment at https://pre-alpha-dev-1.encrypt.ika-network.net:443 – no local setup needed.

Confidential Counter: Building the Program

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

1. Cargo.toml

[package]
name = "confidential-counter-anchor"
edition.workspace = true

[dependencies]
encrypt-types = { workspace = true }
encrypt-dsl = { package = "encrypt-solana-dsl", path = "../../../program-sdk/dsl" }
encrypt-anchor = { workspace = true }
anchor-lang = { workspace = true }

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

Three Encrypt crates:

  • encrypt-types – FHE type definitions (EUint64, Uint64, etc.)
  • encrypt-dsl (aliased from encrypt-solana-dsl) – the #[encrypt_fn] macro that generates FHE graphs + Solana CPI glue
  • encrypt-anchorEncryptContext struct and account helpers for Anchor

2. FHE Graphs

#![allow(unused)]
fn main() {
use encrypt_dsl::prelude::encrypt_fn;
use encrypt_types::encrypted::EUint64;

#[encrypt_fn]
fn increment_graph(value: EUint64) -> EUint64 {
    value + 1
}

#[encrypt_fn]
fn decrement_graph(value: EUint64) -> EUint64 {
    value - 1
}
}

The #[encrypt_fn] macro does two things at compile time:

  1. Generates a graph function (increment_graph() -> Vec<u8>) that returns a serialized computation graph in the Encrypt binary format. The graph has one Input node (the encrypted value), one Constant node (the literal 1), one Op node (add or subtract), and one Output node.

  2. Generates a CPI extension trait (IncrementGraphCpi) with a blanket implementation on EncryptContext. This gives you a method like encrypt_ctx.increment_graph(input_ct, output_ct) that builds and executes the execute_graph CPI to the Encrypt program.

The graph is embedded in the program binary. When the CPI fires, the Encrypt program emits an event that the off-chain executor picks up. The executor deserializes the graph, evaluates each node using real FHE operations, and commits the result ciphertext on-chain.

Key point: the same ciphertext account can be both input and output (in-place update). That’s how increment works – the counter value is updated without creating new accounts.

3. Counter State

#![allow(unused)]
fn main() {
#[account]
#[derive(InitSpace)]
pub struct Counter {
    pub authority: Pubkey,          // who can increment/decrypt
    pub counter_id: [u8; 32],      // unique ID, used as PDA seed
    pub value: [u8; 32],           // pubkey of the ciphertext account
    pub pending_digest: [u8; 32],  // digest from request_decryption
    pub revealed_value: u64,       // plaintext after decryption
    pub bump: u8,                  // PDA bump
}
}
  • value stores the pubkey of a ciphertext account, not the ciphertext itself. Ciphertext accounts are owned by the Encrypt program.
  • pending_digest is the store-and-verify pattern: when you request decryption, the Encrypt program returns a digest of the ciphertext at that moment. You store it and later verify the decryption result matches.
  • revealed_value holds the plaintext once decrypted. Until then it’s 0.

4. create_counter

#![allow(unused)]
fn main() {
pub fn create_counter(
    ctx: Context<CreateCounter>,
    counter_id: [u8; 32],
    initial_value_id: [u8; 32],
) -> Result<()> {
    let ctr = &mut ctx.accounts.counter;
    ctr.authority = ctx.accounts.authority.key();
    ctr.counter_id = counter_id;
    ctr.value = initial_value_id;
    ctr.pending_digest = [0u8; 32];
    ctr.revealed_value = 0;
    ctr.bump = ctx.bumps.counter;
    Ok(())
}
}

The caller creates an encrypted zero off-chain (via the gRPC CreateInput RPC), which produces a ciphertext account on Solana. The caller passes that account’s pubkey as initial_value_id. The counter PDA just stores the reference.

Account constraints:

#![allow(unused)]
fn main() {
#[derive(Accounts)]
#[instruction(counter_id: [u8; 32])]
pub struct CreateCounter<'info> {
    #[account(
        init,
        payer = payer,
        space = 8 + Counter::INIT_SPACE,
        seeds = [b"counter", counter_id.as_ref()],
        bump,
    )]
    pub counter: Account<'info, Counter>,
    pub authority: Signer<'info>,
    #[account(mut)]
    pub payer: Signer<'info>,
    pub system_program: Program<'info, System>,
}
}

The PDA is seeded by ["counter", counter_id]. The counter_id is an arbitrary 32-byte value chosen by the caller (typically a random keypair’s pubkey bytes).

5. increment / decrement

#![allow(unused)]
fn main() {
pub fn increment(ctx: Context<Increment>, cpi_authority_bump: u8) -> Result<()> {
    let encrypt_ctx = EncryptContext {
        encrypt_program: ctx.accounts.encrypt_program.to_account_info(),
        config: ctx.accounts.config.to_account_info(),
        deposit: ctx.accounts.deposit.to_account_info(),
        cpi_authority: ctx.accounts.cpi_authority.to_account_info(),
        caller_program: ctx.accounts.caller_program.to_account_info(),
        network_encryption_key: ctx.accounts.network_encryption_key.to_account_info(),
        payer: ctx.accounts.payer.to_account_info(),
        event_authority: ctx.accounts.event_authority.to_account_info(),
        system_program: ctx.accounts.system_program.to_account_info(),
        cpi_authority_bump,
    };

    let value_ct = ctx.accounts.value_ct.to_account_info();
    encrypt_ctx.increment_graph(value_ct.clone(), value_ct)?;

    Ok(())
}
}

Step by step:

  1. Build an EncryptContext with all the Encrypt program accounts. These are infrastructure accounts (config, deposit, CPI authority PDA, network encryption key, event authority). Every Encrypt CPI needs them.

  2. Call encrypt_ctx.increment_graph(input, output). This method was generated by #[encrypt_fn]. It:

    • Serializes the graph bytes
    • Verifies the input ciphertext’s fhe_type matches EUint64
    • Builds an execute_graph CPI instruction
    • Invokes the Encrypt program
  3. The input and output are the same account (value_ct). This is an in-place update – the executor will overwrite the ciphertext with the computed result.

The cpi_authority_bump is the bump for the PDA ["__encrypt_cpi_authority"] derived from your program ID. The Encrypt program uses this to verify the CPI came from an authorized program.

decrement is identical except it calls encrypt_ctx.decrement_graph(...).

The Increment accounts struct shows the full set of accounts needed for any Encrypt CPI:

#![allow(unused)]
fn main() {
#[derive(Accounts)]
pub struct Increment<'info> {
    #[account(mut)]
    pub counter: Account<'info, Counter>,
    /// CHECK: Value ciphertext account
    #[account(mut)]
    pub value_ct: UncheckedAccount<'info>,
    /// CHECK: Encrypt program
    pub encrypt_program: UncheckedAccount<'info>,
    /// CHECK: Encrypt config
    pub config: UncheckedAccount<'info>,
    /// CHECK: Encrypt deposit
    #[account(mut)]
    pub deposit: UncheckedAccount<'info>,
    /// CHECK: CPI authority PDA
    pub cpi_authority: UncheckedAccount<'info>,
    /// CHECK: Caller program
    pub caller_program: UncheckedAccount<'info>,
    /// CHECK: Network encryption key
    pub network_encryption_key: UncheckedAccount<'info>,
    #[account(mut)]
    pub payer: Signer<'info>,
    /// CHECK: Event authority PDA
    pub event_authority: UncheckedAccount<'info>,
    pub system_program: Program<'info, System>,
}
}

6. request_value_decryption

#![allow(unused)]
fn main() {
pub fn request_value_decryption(
    ctx: Context<RequestValueDecryption>,
    cpi_authority_bump: u8,
) -> Result<()> {
    let ctr = &ctx.accounts.counter;
    require!(
        ctr.authority == ctx.accounts.payer.key(),
        CounterError::Unauthorized
    );

    let encrypt_ctx = EncryptContext { /* ... same fields ... */ };

    let digest = encrypt_ctx.request_decryption(
        &ctx.accounts.request_acct.to_account_info(),
        &ctx.accounts.ciphertext.to_account_info(),
    )?;

    let ctr = &mut ctx.accounts.counter;
    ctr.pending_digest = digest;

    Ok(())
}
}

request_decryption does two things:

  1. Creates a DecryptionRequest account (keypair account, passed as a signer)
  2. Returns a [u8; 32] digest – a snapshot of the ciphertext’s current state

You must store this digest. It prevents stale-value attacks: if someone modifies the ciphertext between your request and the decryptor’s response, the digest won’t match and reveal_value will fail.

The decryption request account is a keypair account (not a PDA). The caller generates a fresh keypair and passes it as a signer. This avoids seed conflicts when making multiple decryption requests.

7. reveal_value

#![allow(unused)]
fn main() {
pub fn reveal_value(ctx: Context<RevealValue>) -> Result<()> {
    let ctr = &mut ctx.accounts.counter;
    require!(
        ctr.authority == ctx.accounts.authority.key(),
        CounterError::Unauthorized
    );

    let expected_digest = &ctr.pending_digest;

    let req_data = ctx.accounts.request_acct.try_borrow_data()?;
    use encrypt_types::encrypted::Uint64;
    let value = encrypt_anchor::accounts::read_decrypted_verified::<Uint64>(
        &req_data,
        expected_digest,
    )
    .map_err(|_| CounterError::DecryptionNotComplete)?;

    ctr.revealed_value = *value;
    Ok(())
}
}

read_decrypted_verified::<Uint64> does three checks:

  1. The decryption request is complete (decryptor has written the plaintext)
  2. The ciphertext digest in the request matches expected_digest
  3. The FHE type matches Uint64 (the plaintext type corresponding to EUint64)

If all checks pass, it returns a reference to the plaintext value. The Uint64 type parameter is the plaintext counterpart of EUint64.

The RevealValue accounts are minimal – no Encrypt CPI needed:

#![allow(unused)]
fn main() {
#[derive(Accounts)]
pub struct RevealValue<'info> {
    #[account(mut)]
    pub counter: Account<'info, Counter>,
    /// CHECK: Completed decryption request account
    pub request_acct: UncheckedAccount<'info>,
    pub authority: Signer<'info>,
}
}

Error Codes

#![allow(unused)]
fn main() {
#[error_code]
pub enum CounterError {
    #[msg("Unauthorized")]
    Unauthorized,
    #[msg("Decryption not complete")]
    DecryptionNotComplete,
}
}

Confidential Counter: Testing

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

1. Unit Tests (Graph Logic)

Unit tests verify the FHE graph produces correct results using a mock evaluator. No SBF build or Solana runtime needed.

cargo test -p confidential-counter-anchor --lib

The tests use a run_mock helper that walks the graph nodes and evaluates them with mock arithmetic (operating on plaintext values encoded as mock digests):

#![allow(unused)]
fn main() {
#[test]
fn increment_from_zero() {
    let r = run_mock(increment_graph, &[0], &[FheType::EUint64]);
    assert_eq!(r[0], 1, "0 + 1 = 1");
}

#[test]
fn increment_from_ten() {
    let r = run_mock(increment_graph, &[10], &[FheType::EUint64]);
    assert_eq!(r[0], 11, "10 + 1 = 11");
}

#[test]
fn decrement_from_ten() {
    let r = run_mock(decrement_graph, &[10], &[FheType::EUint64]);
    assert_eq!(r[0], 9, "10 - 1 = 9");
}

#[test]
fn graph_shapes() {
    let inc = increment_graph();
    let pg = parse_graph(&inc).unwrap();
    assert_eq!(pg.header().num_inputs(), 1);
    assert_eq!(pg.header().num_outputs(), 1);
}
}

2. LiteSVM Integration Tests (E2E)

Full lifecycle tests using LiteSVM – a lightweight Solana runtime that runs in-process. Tests deploy the SBF binary, create ciphertexts, execute graphs, and verify results.

# Build SBF first
just build-sbf-examples

# Run LiteSVM tests
cargo test -p confidential-counter-anchor --test litesvm

The test uses EncryptTestContext which bundles a LiteSVM instance with the Encrypt program pre-deployed and a mock compute engine:

#![allow(unused)]
fn main() {
#[test]
fn test_increment() {
    let mut ctx = EncryptTestContext::new_default();
    let (program_id, cpi_authority, cpi_bump) = setup_anchor_program(&mut ctx);
    let authority = ctx.new_funded_keypair();

    // Create encrypted zero
    let value_ct = ctx.create_input::<Uint64>(0, &program_id);

    // Create counter PDA
    // ... send create_counter ix ...

    // Increment via CPI
    // ... send increment ix ...

    // Simulate executor: evaluate graph + commit result
    let graph = increment_graph();
    ctx.enqueue_graph_execution(&graph, &[value_ct], &[value_ct]);
    ctx.process_pending();
    ctx.register_ciphertext(&value_ct);

    // Verify
    let result = ctx.decrypt_from_store(&value_ct);
    assert_eq!(result, 1);
}
}

Key EncryptTestContext methods:

  • create_input::<Uint64>(value, program_id) – creates a ciphertext account
  • enqueue_graph_execution(graph, inputs, outputs) – queues a graph for mock evaluation
  • process_pending() – runs the mock FHE engine
  • register_ciphertext(pubkey) – syncs the on-chain account with the mock store
  • decrypt_from_store(pubkey) – returns the plaintext value

3. Mollusk Instruction-Level Tests

Mollusk tests individual instructions in isolation without CPI. Useful for testing reveal_value logic (authorization checks, digest verification) without needing the full Encrypt program.

just build-sbf-examples
cargo test -p confidential-counter-anchor --test mollusk

Tests construct raw account data and verify instruction behavior:

#![allow(unused)]
fn main() {
#[test]
fn test_reveal_value_success() {
    let (mollusk, pid) = setup();
    let authority = Pubkey::new_unique();
    let digest = [0xABu8; 32];

    let counter_data = build_anchor_counter_with_digest(
        &authority, &[1u8; 32], &Pubkey::new_unique(), &digest, 0,
    );
    let request_data = build_decryption_request_data(&digest, 42);

    let result = mollusk.process_instruction(/* ... */);
    assert!(result.program_result.is_ok());
    // Check revealed_value == 42
}

#[test]
fn test_reveal_value_rejects_wrong_authority() { /* ... */ }

#[test]
fn test_reveal_value_rejects_digest_mismatch() { /* ... */ }
}

4. Running All Example Tests

# Everything (build + all test types)
just test-examples

# Just LiteSVM e2e
just test-examples-litesvm

# Just Mollusk
just test-examples-mollusk

# Just program-test (solana-program-test runtime)
just test-examples-program-test

Confidential Counter: React Frontend

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

1. Project Setup

The frontend uses Vite + React + Solana wallet adapter.

cd chains/solana/examples/counter/react
bun install

Dependencies in package.json:

{
  "dependencies": {
    "@solana/wallet-adapter-base": "^0.9.23",
    "@solana/wallet-adapter-react": "^0.15.35",
    "@solana/wallet-adapter-react-ui": "^0.9.35",
    "@solana/wallet-adapter-wallets": "^0.19.32",
    "@solana/web3.js": "^1.95.3",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  }
}

Entry point (main.tsx) wraps the app with Solana providers:

const RPC_URL = "https://api.devnet.solana.com";

function Root() {
  const wallets = useMemo(() => [], []);
  return (
    <ConnectionProvider endpoint={RPC_URL}>
      <WalletProvider wallets={wallets} autoConnect>
        <WalletModalProvider>
          <App />
        </WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  );
}

2. Program IDs

const ENCRYPT_PROGRAM = new PublicKey(
  "Cq37zHSH1zB6xomYK2LjP6uXJvLR3uTehxA5W9wgHGvx"
);
const COUNTER_PROGRAM = new PublicKey(
  "CntR1111111111111111111111111111111111111111"
);

Update these to match your deployed program IDs.

3. PDA Derivation

All Encrypt infrastructure PDAs derive from known seeds:

function deriveEncryptPdas(payer: PublicKey) {
  const [configPda] = findPda([Buffer.from("encrypt_config")], ENCRYPT_PROGRAM);
  const [eventAuthority] = findPda([Buffer.from("__event_authority")], ENCRYPT_PROGRAM);
  const [depositPda, depositBump] = findPda(
    [Buffer.from("encrypt_deposit"), payer.toBuffer()], ENCRYPT_PROGRAM
  );
  const networkKey = Buffer.alloc(32, 0x55);
  const [networkKeyPda] = findPda(
    [Buffer.from("network_encryption_key"), networkKey], ENCRYPT_PROGRAM
  );
  const [cpiAuthority, cpiBump] = findPda(
    [Buffer.from("__encrypt_cpi_authority")], COUNTER_PROGRAM
  );
  return { configPda, eventAuthority, depositPda, depositBump, networkKeyPda, cpiAuthority, cpiBump };
}

The cpiAuthority is derived from the counter program (not the Encrypt program). Each program that CPIs into Encrypt has its own CPI authority PDA.

4. Encrypt CPI Account List

Every Encrypt CPI needs these accounts in order:

function encryptCpiAccounts(payer: PublicKey, enc: ReturnType<typeof deriveEncryptPdas>) {
  return [
    { pubkey: ENCRYPT_PROGRAM, isSigner: false, isWritable: false },
    { pubkey: enc.configPda, isSigner: false, isWritable: true },
    { pubkey: enc.depositPda, isSigner: false, isWritable: true },
    { pubkey: enc.cpiAuthority, isSigner: false, isWritable: false },
    { pubkey: COUNTER_PROGRAM, isSigner: false, isWritable: false },
    { pubkey: enc.networkKeyPda, isSigner: false, isWritable: false },
    { pubkey: payer, isSigner: true, isWritable: true },
    { pubkey: enc.eventAuthority, isSigner: false, isWritable: false },
    { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
  ];
}

5. Polling Pattern

After a CPI, the executor processes the FHE computation off-chain. The frontend polls until the ciphertext account is verified:

async function pollUntil(
  connection: any, account: PublicKey,
  check: (data: Buffer) => boolean,
  timeoutMs = 120_000, intervalMs = 1_000
): Promise<Buffer> {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    try {
      const info = await connection.getAccountInfo(account);
      if (info && check(info.data as Buffer)) return info.data as Buffer;
    } catch {}
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error("Timeout waiting for executor");
}

// Ciphertext is verified when status byte (offset 99) == 1
const isVerified = (d: Buffer) => d.length >= 100 && d[99] === 1;

// Decryption is complete when written_bytes == total_bytes and total > 0
const isDecrypted = (d: Buffer) => {
  if (d.length < 107) return false;
  const total = d.readUInt32LE(99);
  const written = d.readUInt32LE(103);
  return written === total && total > 0;
};

6. Create Counter Flow

const handleInitialize = useCallback(async () => {
  await ensureDeposit(); // create deposit account if needed
  const enc = getEnc();
  const id = Buffer.from(Keypair.generate().publicKey.toBytes());
  const [pda, bump] = findPda([Buffer.from("counter"), id], COUNTER_PROGRAM);
  const valueKeypair = Keypair.generate();

  await sendTx(
    [new TransactionInstruction({
      programId: COUNTER_PROGRAM,
      data: Buffer.concat([Buffer.from([0, bump, enc.cpiBump]), id]),
      keys: [
        { pubkey: pda, isSigner: false, isWritable: true },
        { pubkey: wallet.publicKey, isSigner: true, isWritable: false },
        { pubkey: valueKeypair.publicKey, isSigner: true, isWritable: true },
        ...encryptCpiAccounts(wallet.publicKey, enc),
      ],
    })],
    [valueKeypair]
  );

  setCounterPda(pda);
  setValueCt(valueKeypair.publicKey);
}, [/* deps */]);

The valueKeypair is a fresh keypair whose public key becomes the ciphertext account address. The Encrypt program creates this account during the CPI. The keypair must sign the transaction.

7. Increment / Decrement Flow

const handleOp = useCallback(async (opcode: 1 | 2, label: string) => {
  const enc = getEnc();
  await sendTx([new TransactionInstruction({
    programId: COUNTER_PROGRAM,
    data: Buffer.from([opcode, enc.cpiBump]),
    keys: [
      { pubkey: counterPda, isSigner: false, isWritable: true },
      { pubkey: valueCt, isSigner: false, isWritable: true },
      ...encryptCpiAccounts(wallet.publicKey, enc),
    ],
  })]);

  // Wait for executor to process the FHE computation
  await pollUntil(connection, valueCt, isVerified, 60_000);
}, [/* deps */]);

After sending the transaction, poll the ciphertext account until isVerified returns true. The executor typically processes within a few seconds on devnet.

8. Decrypt + Reveal Flow

Decryption is a two-step process:

const handleDecrypt = useCallback(async () => {
  const enc = getEnc();
  const reqKeypair = Keypair.generate();

  // Step 1: Request decryption
  await sendTx(
    [new TransactionInstruction({
      programId: COUNTER_PROGRAM,
      data: Buffer.from([3, enc.cpiBump]),
      keys: [
        { pubkey: counterPda, isSigner: false, isWritable: true },
        { pubkey: reqKeypair.publicKey, isSigner: true, isWritable: true },
        { pubkey: valueCt, isSigner: false, isWritable: false },
        ...encryptCpiAccounts(wallet.publicKey, enc),
      ],
    })],
    [reqKeypair]
  );

  // Step 2: Wait for decryptor
  await pollUntil(connection, reqKeypair.publicKey, isDecrypted);

  // Step 3: Reveal (copy plaintext into counter state)
  await sendTx([new TransactionInstruction({
    programId: COUNTER_PROGRAM,
    data: Buffer.from([4]),
    keys: [
      { pubkey: counterPda, isSigner: false, isWritable: true },
      { pubkey: reqKeypair.publicKey, isSigner: false, isWritable: false },
      { pubkey: wallet.publicKey, isSigner: true, isWritable: false },
    ],
  })]);

  // Read the revealed value from counter PDA
  const data = (await connection.getAccountInfo(counterPda))!.data as Buffer;
  const revealed = data.readBigUInt64LE(129);
  setDisplayValue(revealed.toString());
}, [/* deps */]);

The reqKeypair is a fresh keypair for the decryption request account. After the decryptor writes the result, reveal_value (opcode 4) copies the verified plaintext into counter.revealed_value.

9. Running on Devnet

The app connects to Solana devnet and the pre-alpha executor automatically. No local validator or executor setup is needed.

cd chains/solana/examples/counter/react
bun install
bun dev

Open http://localhost:5173, connect a wallet (e.g. Phantom set to devnet), airdrop SOL, and create a counter.

Encrypted Coin Flip

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Provably fair coin flip with on-chain escrow, built on Encrypt + Solana.

What you’ll learn

  • How XOR on encrypted values produces a provably fair coin flip
  • On-chain escrow pattern for trustless betting
  • The player-vs-house architecture with an automated backend
  • End-to-end flow from encrypted commit to payout

How it works

Two sides each commit an encrypted value (0 or 1). The Encrypt executor computes result = commit_a XOR commit_b using FHE – neither side can see the other’s value before committing. XOR = 1 means side A wins; XOR = 0 means side B wins.

Both sides deposit equal bets into a game PDA. The winner receives 2x from escrow.

Architecture

Player (React)          House (Bun backend)         Solana Program
     |                        |                          |
     |-- create_game -------->|                          |
     |   (encrypt commit,     |                          |
     |    deposit bet)        |                          |
     |                        |                          |
     |-- POST /api/join ----->|                          |
     |                        |-- play ----------------->|
     |                        |   (encrypt commit,       |
     |                        |    match bet, XOR graph) |
     |                        |                          |
     |                        |      Executor computes   |
     |                        |      XOR off-chain       |
     |                        |                          |
     |                        |-- request_decryption --->|
     |                        |-- reveal_result -------->|
     |                        |   (pay winner from PDA)  |
     |                        |                          |
     |<-- GET /api/game ------|                          |
     |   (result: win/lose)   |                          |

Why this is provably fair

  1. Both sides commit encrypted values before seeing the other’s choice
  2. The FHE XOR computation is deterministic – the executor cannot alter it
  3. The on-chain program enforces payout rules – neither side can withhold funds
  4. The ciphertext digest is verified at reveal time – stale or tampered results are rejected

Components

ComponentLocationRole
Solana program (Anchor)anchor/src/lib.rsGame state, escrow, CPI to Encrypt
Solana program (Pinocchio)pinocchio/src/lib.rsSame logic, low-level
House backendreact/server/house.tsAuto-joins games, handles decrypt + reveal
React frontendreact/src/App.tsxPlayer UI: bet, flip, see result

Building the Coin Flip Program

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Step-by-step guide to the Anchor on-chain program.

What you’ll learn

  • How to define an FHE graph with #[encrypt_fn]
  • Game state design with escrow
  • CPI to Encrypt for graph execution and decryption
  • The full instruction set: create, play, decrypt, reveal, cancel

1. The XOR graph

The entire fairness mechanism is a single line:

#![allow(unused)]
fn main() {
use encrypt_dsl::prelude::encrypt_fn;
use encrypt_types::encrypted::EUint64;

#[encrypt_fn]
fn coin_flip_graph(commit_a: EUint64, commit_b: EUint64) -> EUint64 {
    commit_a ^ commit_b
}
}

#[encrypt_fn] compiles this into a binary graph that the Encrypt executor evaluates using FHE. The function itself never runs on-chain – it generates a static graph at compile time. The macro also generates an extension trait (CoinFlipGraphCpi) with a method coin_flip_graph() on EncryptContext that handles the CPI.

Why XOR is fair: If both sides pick the same value (0^0 or 1^1), result = 0 (side B wins). If they differ (0^1 or 1^0), result = 1 (side A wins). Neither side can predict the other’s encrypted value, so both have a 50/50 chance.

2. Game state

#![allow(unused)]
fn main() {
#[account]
#[derive(InitSpace)]
pub struct Game {
    pub side_a: Pubkey,              // game creator
    pub game_id: [u8; 32],          // unique identifier
    pub commit_a: [u8; 32],         // side A's ciphertext account pubkey
    pub result_ct: [u8; 32],        // result ciphertext account pubkey
    pub side_b: Pubkey,             // joiner (zeroed until play)
    pub is_active: bool,
    pub played: bool,               // false=waiting, true=both committed
    pub pending_digest: [u8; 32],   // decryption digest for verification
    pub revealed_result: u8,        // 0=unknown, 1=side_a wins, 2=side_b wins
    pub bet_lamports: u64,
    pub bump: u8,
}
}

Key design choices:

  • commit_a and result_ct store ciphertext account pubkeys (32 bytes each). These are keypair accounts in Encrypt, so pubkey = identifier.
  • pending_digest is set when decryption is requested. At reveal time, we verify the decrypted value matches this digest – preventing stale or tampered results.
  • bet_lamports is the per-side bet. The PDA holds both deposits.

3. create_game – side A deposits and commits

#![allow(unused)]
fn main() {
pub fn create_game(
    ctx: Context<CreateGame>,
    game_id: [u8; 32],
    commit_a_id: [u8; 32],
    result_ct_id: [u8; 32],
    bet_lamports: u64,
) -> Result<()> {
    // Side A deposits bet
    if bet_lamports > 0 {
        system_program::transfer(
            CpiContext::new(
                ctx.accounts.system_program.to_account_info(),
                system_program::Transfer {
                    from: ctx.accounts.payer.to_account_info(),
                    to: ctx.accounts.game.to_account_info(),
                },
            ),
            bet_lamports,
        )?;
    }

    let game = &mut ctx.accounts.game;
    game.side_a = ctx.accounts.side_a.key();
    game.game_id = game_id;
    game.commit_a = commit_a_id;
    game.result_ct = result_ct_id;
    game.side_b = Pubkey::default();
    game.is_active = true;
    game.played = false;
    game.pending_digest = [0u8; 32];
    game.revealed_result = 0;
    game.bet_lamports = bet_lamports;
    game.bump = ctx.bumps.game;
    Ok(())
}
}

The game PDA is derived from ["game", game_id]. Side A’s encrypted commit (commit_a_id) is created before this instruction via gRPC createInput. The result_ct_id is a pre-created plaintext ciphertext (initialized to 0) that will hold the XOR output.

Why pre-create result_ct: Encrypt’s execute_graph writes results into existing ciphertext accounts. The output account must exist before the graph runs. Side A creates it during create_game so it’s ready when side B triggers the XOR.

Account validation:

#![allow(unused)]
fn main() {
#[derive(Accounts)]
#[instruction(game_id: [u8; 32])]
pub struct CreateGame<'info> {
    #[account(
        init,
        payer = payer,
        space = 8 + Game::INIT_SPACE,
        seeds = [b"game", game_id.as_ref()],
        bump,
    )]
    pub game: Account<'info, Game>,
    pub side_a: Signer<'info>,
    #[account(mut)]
    pub payer: Signer<'info>,
    pub system_program: Program<'info, System>,
}
}

4. play – side B matches bet and triggers XOR

#![allow(unused)]
fn main() {
pub fn play(ctx: Context<Play>, cpi_authority_bump: u8) -> Result<()> {
    let game = &ctx.accounts.game;
    require!(game.is_active, CoinFlipError::GameClosed);
    require!(!game.played, CoinFlipError::AlreadyPlayed);

    // Verify ciphertext accounts match game state
    require!(
        ctx.accounts.commit_a_ct.key().to_bytes() == game.commit_a,
        CoinFlipError::InvalidAccount
    );
    require!(
        ctx.accounts.result_ct.key().to_bytes() == game.result_ct,
        CoinFlipError::InvalidAccount
    );

    // Side B matches bet
    let bet = game.bet_lamports;
    if bet > 0 {
        system_program::transfer(
            CpiContext::new(
                ctx.accounts.system_program.to_account_info(),
                system_program::Transfer {
                    from: ctx.accounts.side_b.to_account_info(),
                    to: ctx.accounts.game.to_account_info(),
                },
            ),
            bet,
        )?;
    }

    let encrypt_ctx = EncryptContext {
        encrypt_program: ctx.accounts.encrypt_program.to_account_info(),
        config: ctx.accounts.config.to_account_info(),
        deposit: ctx.accounts.deposit.to_account_info(),
        cpi_authority: ctx.accounts.cpi_authority.to_account_info(),
        caller_program: ctx.accounts.caller_program.to_account_info(),
        network_encryption_key: ctx.accounts.network_encryption_key.to_account_info(),
        payer: ctx.accounts.payer.to_account_info(),
        event_authority: ctx.accounts.event_authority.to_account_info(),
        system_program: ctx.accounts.system_program.to_account_info(),
        cpi_authority_bump,
    };

    let commit_a = ctx.accounts.commit_a_ct.to_account_info();
    let commit_b = ctx.accounts.commit_b_ct.to_account_info();
    let result = ctx.accounts.result_ct.to_account_info();
    encrypt_ctx.coin_flip_graph(commit_a, commit_b, result)?;

    let game = &mut ctx.accounts.game;
    game.side_b = ctx.accounts.side_b.key();
    game.played = true;
    Ok(())
}
}

The coin_flip_graph() method is auto-generated by #[encrypt_fn]. It CPIs into the Encrypt program with the graph bytecode, input ciphertext accounts (commit_a, commit_b), and output account (result). The executor picks this up off-chain, computes the encrypted XOR, and writes the result back to result_ct.

The EncryptContext bundles all the Encrypt program accounts needed for CPI. The cpi_authority is a PDA derived from your program’s ID – it authorizes your program to call Encrypt.

5. request_result_decryption

#![allow(unused)]
fn main() {
pub fn request_result_decryption(
    ctx: Context<RequestResultDecryption>,
    cpi_authority_bump: u8,
) -> Result<()> {
    let game = &ctx.accounts.game;
    require!(game.played, CoinFlipError::NotPlayed);

    let encrypt_ctx = EncryptContext { /* ... same fields ... */ };

    let digest = encrypt_ctx.request_decryption(
        &ctx.accounts.request_acct.to_account_info(),
        &ctx.accounts.result_ciphertext.to_account_info(),
    )?;

    let game = &mut ctx.accounts.game;
    game.pending_digest = digest;
    Ok(())
}
}

request_decryption creates a decryption request account (keypair, not PDA) and returns a 32-byte digest. This digest is a snapshot of the ciphertext’s current state. Storing it in the game ensures that reveal_result verifies against the exact value that was requested for decryption.

Anyone can call this after both sides have played.

6. reveal_result – verify and pay winner

#![allow(unused)]
fn main() {
pub fn reveal_result(ctx: Context<RevealResult>) -> Result<()> {
    let game = &ctx.accounts.game;
    require!(game.played, CoinFlipError::NotPlayed);
    require!(game.revealed_result == 0, CoinFlipError::AlreadyRevealed);

    let expected_digest = &game.pending_digest;

    let req_data = ctx.accounts.request_acct.try_borrow_data()?;
    use encrypt_types::encrypted::Uint64;
    let value = encrypt_anchor::accounts::read_decrypted_verified::<Uint64>(
        &req_data,
        expected_digest,
    )
    .map_err(|_| CoinFlipError::DecryptionNotComplete)?;

    let side_a_wins = *value == 1;
    let expected_winner = if side_a_wins { game.side_a } else { game.side_b };
    require!(
        ctx.accounts.winner.key() == expected_winner,
        CoinFlipError::WrongWinner
    );

    // Pay winner
    let payout = game.bet_lamports * 2;
    if payout > 0 {
        let game_info = ctx.accounts.game.to_account_info();
        let winner_info = ctx.accounts.winner.to_account_info();
        **game_info.lamports.borrow_mut() -= payout;
        **winner_info.lamports.borrow_mut() += payout;
    }

    let game = &mut ctx.accounts.game;
    game.revealed_result = if side_a_wins { 1 } else { 2 };
    game.is_active = false;
    Ok(())
}
}

read_decrypted_verified::<Uint64> reads the decrypted value from the request account and verifies it against the stored digest. If the ciphertext was modified after the decryption request, the digest won’t match and this fails.

The payout uses direct lamport manipulation – the game PDA is program-owned, so we can debit it directly.

7. cancel_game – refund before play

#![allow(unused)]
fn main() {
pub fn cancel_game(ctx: Context<CancelGame>) -> Result<()> {
    let game = &ctx.accounts.game;
    require!(game.is_active, CoinFlipError::GameClosed);
    require!(!game.played, CoinFlipError::AlreadyPlayed);
    require!(
        ctx.accounts.side_a.key() == game.side_a,
        CoinFlipError::Unauthorized
    );

    let bet = game.bet_lamports;
    if bet > 0 {
        let game_info = ctx.accounts.game.to_account_info();
        let side_a_info = ctx.accounts.side_a.to_account_info();
        **game_info.lamports.borrow_mut() -= bet;
        **side_a_info.lamports.borrow_mut() += bet;
    }

    let game = &mut ctx.accounts.game;
    game.is_active = false;
    Ok(())
}
}

Only side A can cancel, and only before side B joins. This prevents griefing – side A can always recover their funds if no opponent shows up.

Instruction summary

DiscInstructionWhoWhen
0create_gameSide AStart – deposit bet, commit encrypted value
1playSide BAfter create – match bet, commit, XOR executes
2request_result_decryptionAnyoneAfter play – triggers MPC decryption
3reveal_resultAnyoneAfter decryption – pays winner 2x from escrow
4cancel_gameSide ABefore play – refund bet

On-Chain Escrow Deep Dive

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

How SOL flows through the coin flip game.

What you’ll learn

  • How the game PDA acts as a trustless escrow
  • System transfer CPI for deposits vs direct lamport manipulation for payouts
  • Cancel refund logic
  • Why this design is secure

SOL flow

Side A wallet ──(system transfer)──> Game PDA ──(lamport manipulation)──> Winner wallet
Side B wallet ──(system transfer)──> Game PDA
  1. Side A deposits during create_game via system program transfer CPI:
#![allow(unused)]
fn main() {
if bet_lamports > 0 {
    system_program::transfer(
        CpiContext::new(
            ctx.accounts.system_program.to_account_info(),
            system_program::Transfer {
                from: ctx.accounts.payer.to_account_info(),
                to: ctx.accounts.game.to_account_info(),
            },
        ),
        bet_lamports,
    )?;
}
}
  1. Side B matches during play with the same pattern:
#![allow(unused)]
fn main() {
let bet = game.bet_lamports;
if bet > 0 {
    system_program::transfer(
        CpiContext::new(
            ctx.accounts.system_program.to_account_info(),
            system_program::Transfer {
                from: ctx.accounts.side_b.to_account_info(),
                to: ctx.accounts.game.to_account_info(),
            },
        ),
        bet,
    )?;
}
}
  1. Winner withdraws during reveal_result via direct lamport manipulation:
#![allow(unused)]
fn main() {
let payout = game.bet_lamports * 2;
if payout > 0 {
    let game_info = ctx.accounts.game.to_account_info();
    let winner_info = ctx.accounts.winner.to_account_info();
    **game_info.lamports.borrow_mut() -= payout;
    **winner_info.lamports.borrow_mut() += payout;
}
}

Why two different transfer methods

Deposits use system program CPI because the source is a user wallet (system-owned account). Only the system program can debit a system-owned account.

Payouts use direct lamport manipulation because the game PDA is owned by our program. The Solana runtime allows a program to freely debit accounts it owns. This is cheaper (no CPI overhead) and simpler.

Cancel refund

Side A can cancel before side B joins:

#![allow(unused)]
fn main() {
pub fn cancel_game(ctx: Context<CancelGame>) -> Result<()> {
    let game = &ctx.accounts.game;
    require!(game.is_active, CoinFlipError::GameClosed);
    require!(!game.played, CoinFlipError::AlreadyPlayed);
    require!(ctx.accounts.side_a.key() == game.side_a, CoinFlipError::Unauthorized);

    let bet = game.bet_lamports;
    if bet > 0 {
        let game_info = ctx.accounts.game.to_account_info();
        let side_a_info = ctx.accounts.side_a.to_account_info();
        **game_info.lamports.borrow_mut() -= bet;
        **side_a_info.lamports.borrow_mut() += bet;
    }

    let game = &mut ctx.accounts.game;
    game.is_active = false;
    Ok(())
}
}

Guards:

  • is_active – can’t cancel an already-finished game
  • !played – can’t cancel after side B committed (funds are locked for the outcome)
  • side_a == signer – only the creator can cancel

Security properties

Neither side can cheat. Both values are encrypted before the other side commits. The XOR graph is deterministic and computed by the executor under FHE – there’s no way to influence the result after committing.

Funds cannot be stolen. The game PDA is program-owned. Only the program’s instructions can debit it. reveal_result requires a valid decrypted value matching the stored digest. The winner account is validated against the game state.

No griefing. Side A can cancel and recover funds if no opponent joins. Once both sides play, the game must resolve – anyone can call request_result_decryption and reveal_result.

No double-payout. revealed_result is checked to be 0 (unknown) before reveal. After payout, it’s set to 1 or 2, preventing replay.

Testing the Coin Flip

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

What you’ll learn

  • Unit testing the FHE graph with mock compute
  • How the mock evaluator works
  • What each test case validates

Graph unit tests

The #[encrypt_fn] macro generates a function that returns the graph bytecode. You can test the graph logic without deploying to Solana by running it through a mock evaluator:

#![allow(unused)]
fn main() {
#[test]
fn xor_same_side_b_wins() {
    let r = run_mock(
        coin_flip_graph,
        &[0, 0],
        &[FheType::EUint64, FheType::EUint64],
    );
    assert_eq!(r[0], 0, "0^0=0 -> side_b wins");
}

#[test]
fn xor_diff_side_a_wins() {
    let r = run_mock(
        coin_flip_graph,
        &[0, 1],
        &[FheType::EUint64, FheType::EUint64],
    );
    assert_eq!(r[0], 1, "0^1=1 -> side_a wins");
}
}

The run_mock helper parses the graph bytecode and evaluates each node using mock digest encoding/decoding. This simulates exactly what the executor does, but with plaintext values encoded as mock identifiers.

Test matrix

InputsXORWinnerTest
0, 00Side Bxor_same_side_b_wins
0, 11Side Axor_diff_side_a_wins
1, 10Side Bxor_both_one_side_b_wins
1, 01Side Axor_one_zero_side_a_wins

Graph shape test

#![allow(unused)]
fn main() {
#[test]
fn graph_shape() {
    let d = coin_flip_graph();
    let pg = parse_graph(&d).unwrap();
    assert_eq!(pg.header().num_inputs(), 2, "commit_a + commit_b");
    assert_eq!(pg.header().num_outputs(), 1, "single flip result");
}
}

Validates that the compiled graph has exactly 2 inputs and 1 output. This catches accidental changes to the graph signature.

Running tests

# Unit tests only (no SBF build needed)
cargo test -p encrypt-coin-flip-anchor

# Or run all example tests
just test-examples

E2E tests

The e2e/ directory contains integration tests that deploy the program to a local validator (LiteSVM or solana-program-test), run the full flow (create game, play, decrypt, reveal), and verify the winner gets paid. These require the SBF binary:

just build-sbf-examples
just test-examples-litesvm

Building the Full-Stack Coin Flip App

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

React frontend + Bun house backend.

What you’ll learn

  • The player-vs-house architecture
  • How the browser encrypts locally and sends ciphertext via gRPC-Web
  • How the house backend auto-resolves games
  • Frontend flow: bet, flip, poll, result

Architecture

React App (:5173)        House Backend (:3001)       Executor (:50051)
     |                        |                          |
     |-- encryptValue() ----->|                          |
     |-- gRPC-Web createInput =========================>|
     |<- ciphertextId ================================--|
     |                        |                          |
     |-- create_game tx ----->| (on-chain)               |
     |                        |                          |
     |-- POST /api/join ----->|                          |
     |                        |-- gRPC createInput ----->|
     |                        |-- play tx --------------->|
     |                        |-- poll result_ct -------->|
     |                        |-- request_decryption ---->|
     |                        |-- poll decryption ------->|
     |                        |-- reveal_result --------->|
     |                        |                          |
     |-- GET /api/game ------>|                          |
     |<- { status, result } --|                          |

The player encrypts locally in the browser and sends ciphertext directly to the executor via gRPC-Web (fetch()-based, no special proxy). The house backend runs as an automated counterparty – it loads a persistent keypair from HOUSE_SECRET_KEY in the .env file and handles everything after the player creates a game.

House backend

The backend (react/server/house.ts) has two responsibilities:

1. Join games as side B.

When the frontend calls POST /api/join, the backend:

  • Reads the game PDA to get commit_a, result_ct, and bet_lamports
  • Creates its own encrypted commit via gRPC
  • Sends the play instruction (matches bet + triggers XOR graph)
// House creates encrypted commit
const houseVal = Math.random() < 0.5 ? 0 : 1;
const { ciphertextIdentifiers } = await encryptClient.createInput({
  chain: Chain.Solana,
  inputs: [{ ciphertextBytes: mockCiphertext(BigInt(houseVal)), fheType: FHE_UINT64 }],
  authorized: COINFLIP_PROGRAM.toBytes(),
  networkEncryptionPublicKey: networkKey,
});
const commitB = new PublicKey(ciphertextIdentifiers[0]);

// Send play instruction
await sendTx([new TransactionInstruction({
  programId: COINFLIP_PROGRAM,
  data: Buffer.from([1, cpiBump]),
  keys: [
    { pubkey: gamePda, isSigner: false, isWritable: true },
    { pubkey: house.publicKey, isSigner: true, isWritable: true },
    { pubkey: commitA, isSigner: false, isWritable: true },
    { pubkey: commitB, isSigner: false, isWritable: true },
    { pubkey: resultCt, isSigner: false, isWritable: true },
    ...encCpi(),
  ],
})]);

2. Resolve the game.

After play, the backend polls result_ct until the executor commits the XOR result (status = VERIFIED). Then it requests decryption, polls until complete, reads the result, and sends reveal_result to pay the winner:

// Poll for XOR computation
await pollUntil(resultCt, isVerified, 60_000);

// Request decryption
const decReq = Keypair.generate();
await sendTx([new TransactionInstruction({
  programId: COINFLIP_PROGRAM,
  data: Buffer.from([2, cpiBump]),
  keys: [
    { pubkey: gamePda, isSigner: false, isWritable: true },
    { pubkey: decReq.publicKey, isSigner: true, isWritable: true },
    { pubkey: resultCt, isSigner: false, isWritable: false },
    ...encCpi(),
  ],
})], [decReq]);

// Poll for decryption
await pollUntil(decReq.publicKey, isDecrypted);

// Read result and reveal
const reqData = (await connection.getAccountInfo(decReq.publicKey))!.data as Buffer;
const xor = reqData.readBigUInt64LE(107);
const sideAWins = xor === 1n;
const winner = sideAWins ? sideA : house.publicKey;

await sendTx([new TransactionInstruction({
  programId: COINFLIP_PROGRAM,
  data: Buffer.from([3]),
  keys: [
    { pubkey: gamePda, isSigner: false, isWritable: true },
    { pubkey: decReq.publicKey, isSigner: false, isWritable: false },
    { pubkey: house.publicKey, isSigner: true, isWritable: false },
    { pubkey: winner, isSigner: false, isWritable: true },
  ],
})]);

React frontend

The frontend (react/src/App.tsx) handles wallet connection, bet input, and game lifecycle.

Player flow:

  1. Connect wallet (Solana wallet adapter)
  2. Enter bet amount in SOL
  3. Click “Flip”
  4. Frontend encrypts commit locally and sends ciphertext to executor via gRPC-Web
  5. Frontend sends create_game transaction (deposits bet, stores commit)
  6. Frontend calls POST /api/join to tell house to play
  7. Frontend polls GET /api/game/:pda for status updates
  8. Display result: win (+2x bet) or lose

Creating the game on-chain:

The player’s commit is encrypted in the browser – the plaintext never leaves the client. encryptValue() is a client-side mock encryption function (production: WASM FHE encryptor). gRPC-Web works via fetch() – no special proxy needed; the executor’s tonic-web layer handles it.

import { createEncryptWebClient, encryptValue, Chain } from "@encrypt.xyz/pre-alpha-solana-client/grpc-web";

const grpcClient = createEncryptWebClient("https://pre-alpha-dev-1.encrypt.ika-network.net:443");

const playerVal = Math.random() < 0.5 ? 0 : 1;
const ids = await grpcClient.createInput({
  chain: Chain.SOLANA,
  inputs: [{ ciphertextBytes: encryptValue(BigInt(playerVal)), fheType: FHE_UINT64 }],
  authorized: COINFLIP_PROGRAM.toBytes(),
  networkEncryptionPublicKey: networkKey,
});
const commitACt = new PublicKey(ids[0]);

const gameId = Buffer.from(Keypair.generate().publicKey.toBytes());
const [gamePda, gameBump] = findPda([Buffer.from("game"), gameId], COINFLIP_PROGRAM);
const resultCt = Keypair.generate();

const createData = Buffer.alloc(43);
createData[0] = 0; // discriminator
createData[1] = gameBump;
createData[2] = enc.cpiBump;
gameId.copy(createData, 3);
createData.writeBigUInt64LE(BigInt(betLamports), 35);

const tx = new Transaction().add(new TransactionInstruction({
  programId: COINFLIP_PROGRAM,
  data: createData,
  keys: [
    { pubkey: gamePda, isSigner: false, isWritable: true },
    { pubkey: wallet.publicKey, isSigner: true, isWritable: false },
    { pubkey: commitACt, isSigner: false, isWritable: false },
    { pubkey: resultCt.publicKey, isSigner: true, isWritable: true },
    { pubkey: ENCRYPT_PROGRAM, isSigner: false, isWritable: false },
    { pubkey: enc.configPda, isSigner: false, isWritable: false },
    { pubkey: enc.depositPda, isSigner: false, isWritable: true },
    { pubkey: enc.cpiAuthority, isSigner: false, isWritable: false },
    { pubkey: COINFLIP_PROGRAM, isSigner: false, isWritable: false },
    { pubkey: enc.networkKeyPda, isSigner: false, isWritable: false },
    { pubkey: wallet.publicKey, isSigner: true, isWritable: true },
    { pubkey: enc.eventAuthority, isSigner: false, isWritable: false },
    { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
  ],
}));
await wallet.sendTransaction(tx, connection, { signers: [resultCt] });

Polling for result:

const start = Date.now();
while (Date.now() - start < 120_000) {
  const r = await fetch(`${HOUSE_API}/api/game/${gamePda.toBase58()}`);
  const state = await r.json();
  if (state.status === "resolved") {
    const won = state.result === 1;
    setResult(won ? "win" : "lose");
    return;
  }
  await new Promise((r) => setTimeout(r, 800));
}

Encrypt deposit

Both the frontend and house backend need an Encrypt deposit account before they can use Encrypt CPIs. The frontend creates one on first use:

const ensureDeposit = async () => {
  if (await connection.getAccountInfo(enc.depositPda)) return; // already exists
  const data = Buffer.alloc(18);
  data[0] = 14; // create_deposit discriminator
  data[1] = enc.depositBump;
  const tx = new Transaction().add(new TransactionInstruction({
    programId: ENCRYPT_PROGRAM, data,
    keys: [/* deposit PDA, config, payer, vault, system_program */],
  }));
  await wallet.sendTransaction(tx, connection);
};

Running on Devnet

The app connects to Solana devnet and the pre-alpha executor automatically. No local validator or executor setup is needed.

# Set the house secret key in the .env (Bun loads from the react/ directory)
# Supports base58 or JSON array format
echo 'HOUSE_SECRET_KEY=[1,2,3,...,64 bytes]' >> chains/solana/examples/coin-flip/react/.env

# Fund the house wallet on devnet
solana airdrop 2 <HOUSE_PUBLIC_KEY> --url devnet

# Terminal 1: Start the house backend
cd chains/solana/examples/coin-flip/react
bun server/house.ts

# Terminal 2: Start the React dev server
cd chains/solana/examples/coin-flip/react
bun run dev

Open http://localhost:5173, connect a wallet (e.g. Phantom set to devnet), airdrop SOL, and flip.

Confidential Voting

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Encrypted voting where individual votes are hidden but the tally is computed via FHE.

What you’ll learn

  • How FHE enables private voting with public tallies
  • The architecture: React frontend (gRPC-Web) + Bun backend + Solana program + executor
  • End-to-end flow from encrypted vote to revealed results

How it works

Voters cast encrypted yes/no votes (EBool). The on-chain program CPIs into Encrypt to run an FHE graph that conditionally increments encrypted yes or no counters. Nobody – not the program, not the executor, not other voters – can see individual votes. Only when the proposal authority closes voting and requests decryption are the final tallies revealed.

Architecture

Voter (React)           Backend (Bun)              Executor (:50051)
     |                        |                          |
     |-- create_proposal ---->|                          |
     |   (creates encrypted   |                          |
     |    zero counters)      |                          |
     |                        |                          |
     |-- encryptValue() ----->|                          |
     |-- gRPC-Web createInput =========================>|
     |<- ciphertextId ================================--|
     |                        |                          |
     |-- cast_vote tx ------->|                          |
     |   (encrypted vote +    |     Executor computes    |
     |    graph executes)     |     conditional add      |
     |                        |                          |
     |-- close_proposal ----->|                          |
     |                        |                          |
     |-- POST /api/decrypt -->|-- request_decryption --->|
     |                        |-- poll for result ------>|
     |<- decryption ready ----|                          |
     |                        |                          |
     |-- reveal_tally tx ---->|                          |
     |   (read + store        |                          |
     |    plaintext on-chain) |                          |

The browser encrypts votes locally and sends ciphertext directly to the executor via gRPC-Web – the plaintext never leaves the client. The backend only handles decryption requests and polling.

Privacy guarantees

  • Individual votes are hidden. Each vote is an encrypted boolean. The graph operates on ciphertexts – the executor never sees plaintext votes.
  • Tallies are computed homomorphically. The yes/no counters are encrypted integers. Each vote conditionally adds 1 to one counter without decrypting either.
  • Only the authority can reveal. Decryption requires the proposal authority to request it and sign the reveal transaction.
  • Double-voting is prevented. A VoteRecord PDA per voter per proposal enforces one vote each.

Components

ComponentLocationRole
Solana program (Anchor)anchor/src/lib.rsProposal state, vote graph CPI, tally reveal
Solana program (Pinocchio)pinocchio/src/lib.rsSame logic, low-level
Backendreact/server/backend.tsDecryption request + polling
React frontendreact/src/App.tsxCreate proposals, vote, close, reveal

Building the Voting Program

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Step-by-step guide to the Anchor on-chain program.

What you’ll learn

  • How to define an FHE graph with conditional logic (if/else compiles to Select)
  • Proposal state with encrypted counters
  • Update-mode ciphertexts (same account as input and output)
  • VoteRecord PDA for double-vote prevention
  • The decrypt-then-reveal pattern for tallies

1. The cast_vote graph

#![allow(unused)]
fn main() {
use encrypt_dsl::prelude::encrypt_fn;
use encrypt_types::encrypted::{EBool, EUint64};

#[encrypt_fn]
fn cast_vote_graph(
    yes_count: EUint64,
    no_count: EUint64,
    vote: EBool,
) -> (EUint64, EUint64) {
    let new_yes = if vote { yes_count + 1 } else { yes_count };
    let new_no = if vote { no_count } else { no_count + 1 };
    (new_yes, new_no)
}
}

This graph takes three encrypted inputs and produces two encrypted outputs:

  • yes_count / no_count – current encrypted tallies (EUint64)
  • vote – the voter’s encrypted choice (EBool: true = yes, false = no)

The if vote { ... } else { ... } syntax compiles to a Select operation in the FHE graph. Select is a ternary: Select(condition, if_true, if_false). The executor evaluates this homomorphically – it never learns whether the voter chose yes or no.

The graph returns a tuple (new_yes, new_no). If vote = true, new_yes = yes_count + 1 and new_no = no_count (unchanged). If vote = false, the reverse.

#[encrypt_fn] generates a CastVoteGraphCpi trait with a cast_vote_graph() method on EncryptContext. The method takes 3 input accounts and 2 output accounts.

2. Proposal state

#![allow(unused)]
fn main() {
#[account]
#[derive(InitSpace)]
pub struct Proposal {
    pub authority: Pubkey,            // who can close + reveal
    pub proposal_id: [u8; 32],
    pub yes_count: [u8; 32],         // ciphertext account pubkey
    pub no_count: [u8; 32],          // ciphertext account pubkey
    pub is_open: bool,
    pub total_votes: u64,            // plaintext counter (for UI)
    pub revealed_yes: u64,           // written at reveal time
    pub revealed_no: u64,            // written at reveal time
    pub pending_yes_digest: [u8; 32],
    pub pending_no_digest: [u8; 32],
    pub bump: u8,
}
}

yes_count and no_count store ciphertext account pubkeys. These are the encrypted counters that get updated with every vote. pending_yes_digest and pending_no_digest are set when decryption is requested, used to verify the reveal.

#![allow(unused)]
fn main() {
#[account]
#[derive(InitSpace)]
pub struct VoteRecord {
    pub voter: Pubkey,
    pub bump: u8,
}
}

VoteRecord is a PDA derived from ["vote", proposal_id, voter_pubkey]. If it already exists, Anchor’s init constraint fails, preventing double votes.

3. create_proposal – initialize encrypted zero counters

#![allow(unused)]
fn main() {
pub fn create_proposal(
    ctx: Context<CreateProposal>,
    proposal_id: [u8; 32],
    initial_yes_id: [u8; 32],
    initial_no_id: [u8; 32],
) -> Result<()> {
    let prop = &mut ctx.accounts.proposal;
    prop.authority = ctx.accounts.authority.key();
    prop.proposal_id = proposal_id;
    prop.yes_count = initial_yes_id;
    prop.no_count = initial_no_id;
    prop.is_open = true;
    prop.total_votes = 0;
    prop.bump = ctx.bumps.proposal;
    Ok(())
}
}

The initial_yes_id and initial_no_id are ciphertext accounts pre-created with create_plaintext_typed::<Uint64>(0). They start as encrypted zeros. The frontend creates these keypair accounts and passes their pubkeys.

Account validation:

#![allow(unused)]
fn main() {
#[derive(Accounts)]
#[instruction(proposal_id: [u8; 32])]
pub struct CreateProposal<'info> {
    #[account(
        init,
        payer = payer,
        space = 8 + Proposal::INIT_SPACE,
        seeds = [b"proposal", proposal_id.as_ref()],
        bump,
    )]
    pub proposal: Account<'info, Proposal>,
    pub authority: Signer<'info>,
    #[account(mut)]
    pub payer: Signer<'info>,
    pub system_program: Program<'info, System>,
}
}

4. cast_vote – encrypted vote with update-mode ciphertexts

#![allow(unused)]
fn main() {
pub fn cast_vote(
    ctx: Context<CastVote>,
    cpi_authority_bump: u8,
) -> Result<()> {
    let prop = &ctx.accounts.proposal;
    require!(prop.is_open, VotingError::ProposalClosed);

    let encrypt_ctx = EncryptContext {
        encrypt_program: ctx.accounts.encrypt_program.to_account_info(),
        config: ctx.accounts.config.to_account_info(),
        deposit: ctx.accounts.deposit.to_account_info(),
        cpi_authority: ctx.accounts.cpi_authority.to_account_info(),
        caller_program: ctx.accounts.caller_program.to_account_info(),
        network_encryption_key: ctx.accounts.network_encryption_key.to_account_info(),
        payer: ctx.accounts.payer.to_account_info(),
        event_authority: ctx.accounts.event_authority.to_account_info(),
        system_program: ctx.accounts.system_program.to_account_info(),
        cpi_authority_bump,
    };

    let yes_ct = ctx.accounts.yes_ct.to_account_info();
    let no_ct = ctx.accounts.no_ct.to_account_info();
    let vote_ct = ctx.accounts.vote_ct.to_account_info();
    encrypt_ctx.cast_vote_graph(
        yes_ct.clone(), no_ct.clone(), vote_ct,
        yes_ct, no_ct,
    )?;

    let prop = &mut ctx.accounts.proposal;
    prop.total_votes += 1;

    let vr = &mut ctx.accounts.vote_record;
    vr.voter = ctx.accounts.voter.key();
    vr.bump = ctx.bumps.vote_record;

    Ok(())
}
}

Update mode: Notice that yes_ct and no_ct appear as both inputs and outputs:

#![allow(unused)]
fn main() {
encrypt_ctx.cast_vote_graph(
    yes_ct.clone(), no_ct.clone(), vote_ct,  // inputs: yes, no, vote
    yes_ct, no_ct,                            // outputs: yes, no
)?;
}

The same ciphertext accounts are read (current tally) and written (new tally). The executor reads the current encrypted value, computes the graph, and writes the result back to the same account. This avoids creating new ciphertext accounts for every vote.

The vote ciphertext (vote_ct) is created before this instruction. The browser encrypts the vote locally via encryptValue() and sends the ciphertext directly to the executor via gRPC-Web createInput. It’s an encrypted boolean authorized to the voting program.

Double-vote prevention: The vote_record account uses Anchor’s init constraint:

#![allow(unused)]
fn main() {
#[account(
    init,
    payer = payer,
    space = 8 + VoteRecord::INIT_SPACE,
    seeds = [b"vote", proposal.proposal_id.as_ref(), voter.key().as_ref()],
    bump,
)]
pub vote_record: Account<'info, VoteRecord>,
}

If the voter has already voted on this proposal, the PDA already exists and init fails. Simple and gas-efficient.

5. close_proposal – lock voting

#![allow(unused)]
fn main() {
pub fn close_proposal(ctx: Context<CloseProposal>) -> Result<()> {
    let prop = &mut ctx.accounts.proposal;
    require!(
        prop.authority == ctx.accounts.authority.key(),
        VotingError::Unauthorized
    );
    require!(prop.is_open, VotingError::ProposalClosed);
    prop.is_open = false;
    Ok(())
}
}

Only the authority can close. After closing, no more votes can be cast (the cast_vote guard checks is_open). Decryption can only be requested after closing.

6. request_tally_decryption – two separate requests

#![allow(unused)]
fn main() {
pub fn request_tally_decryption(
    ctx: Context<RequestTallyDecryption>,
    is_yes: bool,
    cpi_authority_bump: u8,
) -> Result<()> {
    let prop = &ctx.accounts.proposal;
    require!(!prop.is_open, VotingError::ProposalStillOpen);

    let encrypt_ctx = EncryptContext { /* ... */ };

    let digest = encrypt_ctx.request_decryption(
        &ctx.accounts.request_acct.to_account_info(),
        &ctx.accounts.ciphertext.to_account_info(),
    )?;

    let prop = &mut ctx.accounts.proposal;
    if is_yes {
        prop.pending_yes_digest = digest;
    } else {
        prop.pending_no_digest = digest;
    }
    Ok(())
}
}

Each ciphertext (yes_count, no_count) needs its own decryption request. The is_yes flag determines which digest to store. You call this instruction twice – once for yes, once for no.

The request_acct is a fresh keypair account that the decryptor network will write the plaintext into.

7. reveal_tally – read decrypted values

#![allow(unused)]
fn main() {
pub fn reveal_tally(ctx: Context<RevealTally>, is_yes: bool) -> Result<()> {
    let prop = &mut ctx.accounts.proposal;
    require!(
        prop.authority == ctx.accounts.authority.key(),
        VotingError::Unauthorized
    );
    require!(!prop.is_open, VotingError::ProposalStillOpen);

    let expected_digest = if is_yes {
        &prop.pending_yes_digest
    } else {
        &prop.pending_no_digest
    };

    let req_data = ctx.accounts.request_acct.try_borrow_data()?;
    use encrypt_types::encrypted::Uint64;
    let value = encrypt_anchor::accounts::read_decrypted_verified::<Uint64>(
        &req_data, expected_digest,
    ).map_err(|_| VotingError::DecryptionNotComplete)?;

    if is_yes {
        prop.revealed_yes = *value;
    } else {
        prop.revealed_no = *value;
    }
    Ok(())
}
}

read_decrypted_verified checks that the decrypted value’s digest matches what was stored at request time. This prevents reading stale or tampered values. Called twice – once for yes, once for no. Only the authority can reveal.

Instruction summary

DiscInstructionWhoWhen
0create_proposalAuthorityStart – creates encrypted zero counters
1cast_voteAny voterWhile open – encrypted vote, graph updates counters
2close_proposalAuthorityAfter voting ends – locks further votes
3request_tally_decryptionAnyoneAfter close – one call per counter (yes/no)
4reveal_tallyAuthorityAfter decryption – writes plaintext to proposal

Testing Confidential Voting

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

What you’ll learn

  • Unit testing the cast_vote FHE graph
  • How conditional logic (Select) is tested
  • What each test case validates

Graph unit tests

The #[encrypt_fn] macro generates a function returning the graph bytecode. Test it with a mock evaluator:

#![allow(unused)]
fn main() {
#[test]
fn vote_yes_increments_yes_count() {
    let r = run_mock(
        cast_vote_graph,
        &[10, 5, 1],  // yes_count=10, no_count=5, vote=true
        &[FheType::EUint64, FheType::EUint64, FheType::EBool],
    );
    assert_eq!(r[0], 11);  // yes_count incremented
    assert_eq!(r[1], 5);   // no_count unchanged
}

#[test]
fn vote_no_increments_no_count() {
    let r = run_mock(
        cast_vote_graph,
        &[10, 5, 0],  // yes_count=10, no_count=5, vote=false
        &[FheType::EUint64, FheType::EUint64, FheType::EBool],
    );
    assert_eq!(r[0], 10);  // yes_count unchanged
    assert_eq!(r[1], 6);   // no_count incremented
}

#[test]
fn vote_from_zero() {
    let r = run_mock(
        cast_vote_graph,
        &[0, 0, 1],  // both counters at zero, vote yes
        &[FheType::EUint64, FheType::EUint64, FheType::EBool],
    );
    assert_eq!(r[0], 1);
    assert_eq!(r[1], 0);
}
}

The run_mock helper parses the graph bytecode and evaluates nodes using mock digest encoding. It handles the Select operation (op_type 60) which is what if vote { ... } else { ... } compiles to.

Test matrix

yes_countno_countvotenew_yesnew_noTest
105true115vote_yes_increments_yes_count
105false106vote_no_increments_no_count
00true10vote_from_zero

Graph shape test

#![allow(unused)]
fn main() {
#[test]
fn graph_shape() {
    let d = cast_vote_graph();
    let pg = parse_graph(&d).unwrap();
    assert_eq!(pg.header().num_inputs(), 3);  // yes_count, no_count, vote
    assert_eq!(pg.header().num_outputs(), 2); // new_yes, new_no
}
}

The graph has 3 inputs (two counters + one boolean vote) and 2 outputs (updated counters). This catches signature changes.

Running tests

# Unit tests only (no SBF build needed)
cargo test -p encrypt-voting-anchor

# All example tests
just test-examples

# E2E with LiteSVM
just build-sbf-examples
just test-examples-litesvm

E2E tests

The e2e/ directory contains integration tests that deploy the program, create a proposal, cast multiple votes, close, decrypt, and verify the tallies match. These require the SBF binary and exercise the full Encrypt CPI flow.

Building the Voting App

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

React frontend — fully client-side, no backend needed.

What you’ll learn

  • How encrypted votes are created locally and cast
  • How the authority requests decryption and reveals results directly from the browser
  • Multi-wallet support via URL sharing

Architecture

React App (:5173)                              Executor (:50051)
     |                                              |
     |-- encryptValue() (local)                     |
     |-- gRPC-Web createInput =====================>|
     |<- ciphertextId ============================--|
     |                                              |
     |-- create_proposal tx (on-chain) ------------>|
     |-- cast_vote tx ----------------------------->|
     |                          Executor computes   |
     |                          conditional add     |
     |                                              |
     |-- close_proposal tx ------------------------>|
     |                                              |
     |-- request_tally_decryption tx x2 ----------->|
     |   (yes + no, authority signs)                |
     |-- poll for decryption results -------------->|
     |                                              |
     |-- reveal_tally tx x2 ----------------------->|
     |   (authority signs)                          |
     |                                              |
     |-- read proposal account for final counts     |

Everything happens in the browser. The voter encrypts locally and sends ciphertext to the executor via gRPC-Web. The authority requests decryption and reveals results by signing transactions with their wallet — no backend keypair needed.

React frontend

The frontend (react/src/App.tsx) handles the full proposal lifecycle.

Creating a proposal:

The frontend creates the proposal PDA and two ciphertext keypair accounts (yes_count, no_count) initialized to encrypted zero:

const proposalId = Buffer.from(Keypair.generate().publicKey.toBytes());
const [pda, bump] = findPda([Buffer.from("proposal"), proposalId], VOTING_PROGRAM);
const yesCt = Keypair.generate();
const noCt = Keypair.generate();

const tx = new Transaction().add(new TransactionInstruction({
  programId: VOTING_PROGRAM,
  data: createData,
  keys: [
    { pubkey: pda, isSigner: false, isWritable: true },
    { pubkey: wallet.publicKey, isSigner: true, isWritable: false },
    { pubkey: yesCt.publicKey, isSigner: true, isWritable: true },
    { pubkey: noCt.publicKey, isSigner: true, isWritable: true },
    // ... encrypt program accounts ...
  ],
}));
await wallet.sendTransaction(tx, connection, { signers: [yesCt, noCt] });

Casting a vote:

  1. Encrypt the vote locally via encryptValue() and send ciphertext to executor via gRPC-Web
  2. If previous votes exist, wait for ciphertext accounts to reach VERIFIED status (the executor must finish the previous graph before a new one can use the same accounts)
  3. Send cast_vote transaction with the encrypted vote + proposal’s yes/no ciphertext accounts

The plaintext never leaves the browser. encryptValue() is client-side mock encryption (production: WASM FHE encryptor). gRPC-Web works via fetch() – no special proxy needed; the executor uses tonic-web.

import { createEncryptWebClient, encryptValue, Chain } from "@encrypt.xyz/pre-alpha-solana-client/grpc-web";

const grpcClient = createEncryptWebClient("https://pre-alpha-dev-1.encrypt.ika-network.net:443");

const voteVal = voteYes ? 1 : 0;
const ids = await grpcClient.createInput({
  chain: Chain.SOLANA,
  inputs: [{ ciphertextBytes: encryptValue(voteVal), fheType: FHE_BOOL }],
  authorized: VOTING_PROGRAM.toBytes(),
  networkEncryptionPublicKey: networkKey,
});
const voteCt = new PublicKey(ids[0]);

// Wait for previous vote's computation to finish
if (proposal.totalVotes > 0) {
  await pollUntil(connection, proposal.yesCt, isVerified, 60_000);
}

const ix = new TransactionInstruction({
  programId: VOTING_PROGRAM,
  data: Buffer.from([1, vrBump, cpiBump]),
  keys: [
    { pubkey: proposal.pda, isSigner: false, isWritable: true },
    { pubkey: voteRecord, isSigner: false, isWritable: true },
    { pubkey: wallet.publicKey, isSigner: true, isWritable: false },
    { pubkey: voteCt, isSigner: false, isWritable: true },
    { pubkey: proposal.yesCt, isSigner: false, isWritable: true },
    { pubkey: proposal.noCt, isSigner: false, isWritable: true },
    // ... encrypt program accounts ...
  ],
});
await wallet.sendTransaction(new Transaction().add(ix), connection);

Decrypting and revealing:

The authority handles decryption entirely from the browser — no backend needed. The wallet signs the decryption request and reveal transactions directly:

// 1. Request decryption for yes tally
const yesReq = Keypair.generate();
await sendTx([new TransactionInstruction({
  programId: VOTING_PROGRAM,
  data: Buffer.from([3, cpiBump, 1]),  // disc=3, is_yes=1
  keys: [
    { pubkey: proposal.pda, isSigner: false, isWritable: true },
    { pubkey: yesReq.publicKey, isSigner: true, isWritable: true },
    { pubkey: proposal.yesCt, isSigner: false, isWritable: false },
    ...encCpi(),
  ],
})], [yesReq]);

// 2. Request decryption for no tally
const noReq = Keypair.generate();
await sendTx([new TransactionInstruction({
  programId: VOTING_PROGRAM,
  data: Buffer.from([3, cpiBump, 0]),  // disc=3, is_yes=0
  keys: [
    { pubkey: proposal.pda, isSigner: false, isWritable: true },
    { pubkey: noReq.publicKey, isSigner: true, isWritable: true },
    { pubkey: proposal.noCt, isSigner: false, isWritable: false },
    ...encCpi(),
  ],
})], [noReq]);

// 3. Poll until both are decrypted
await pollUntil(connection, yesReq.publicKey, isDecrypted);
await pollUntil(connection, noReq.publicKey, isDecrypted);

// 4. Reveal yes (authority signature required)
await sendTx([new TransactionInstruction({
  programId: VOTING_PROGRAM,
  data: Buffer.from([4, 1]),  // disc=4, is_yes=1
  keys: [
    { pubkey: proposal.pda, isSigner: false, isWritable: true },
    { pubkey: yesReq.publicKey, isSigner: false, isWritable: false },
    { pubkey: wallet.publicKey, isSigner: true, isWritable: false },
  ],
})]);

// 5. Reveal no
await sendTx([new TransactionInstruction({
  programId: VOTING_PROGRAM,
  data: Buffer.from([4, 0]),  // disc=4, is_yes=0
  keys: [
    { pubkey: proposal.pda, isSigner: false, isWritable: true },
    { pubkey: noReq.publicKey, isSigner: false, isWritable: false },
    { pubkey: wallet.publicKey, isSigner: true, isWritable: false },
  ],
})]);

// 6. Read final results from on-chain proposal account
const propData = (await connection.getAccountInfo(proposal.pda))!.data as Buffer;
const yesCount = Number(propData.readBigUInt64LE(138));
const noCount = Number(propData.readBigUInt64LE(146));

Multi-wallet support via URL sharing

When a proposal is created, the URL is updated with query params:

const params = new URLSearchParams({
  proposal: pda.toBase58(),
  yesCt: yesCt.toBase58(),
  noCt: noCt.toBase58(),
});
window.history.replaceState({}, "", `?${params}`);

Other voters can open this URL in their browser. On mount, the app reads the URL params and loads the proposal from on-chain state:

useEffect(() => {
  const params = new URLSearchParams(window.location.search);
  const pdaStr = params.get("proposal");
  const yesStr = params.get("yesCt");
  const noStr = params.get("noCt");
  if (pdaStr && yesStr && noStr) {
    const pda = new PublicKey(pdaStr);
    connection.getAccountInfo(pda).then((info) => {
      if (!info) return;
      const d = info.data as Buffer;
      const isOpen = d[129] === 1;
      const totalVotes = Number(d.readBigUInt64LE(130));
      setProposal({ pda, yesCt: new PublicKey(yesStr), noCt: new PublicKey(noStr), isOpen, totalVotes, /* ... */ });
    });
  }
}, [connection]);

A “Copy Voting Link” button makes sharing easy.

Running on Devnet

The app connects to Solana devnet and the pre-alpha executor automatically. No local validator or executor setup is needed.

cd chains/solana/examples/voting/react
bun run dev

Open http://localhost:5173, connect a wallet (e.g. Phantom set to devnet), airdrop SOL, create a proposal, share the link with other wallets, vote, close, and decrypt.

E2E Confidential Voting Demo

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Run a full confidential voting scenario against the Encrypt pre-alpha on Solana devnet.

Prerequisites

  • Rust toolchain (edition 2024)
  • just command runner
  • bun (for TypeScript demos)
  • Solana CLI (for airdrop)

Install JS dependencies:

bun install

Quick Start

The demos connect to the pre-alpha executor on devnet automatically:

just demo-web3 <ENCRYPT_PROGRAM_ID> <VOTING_PROGRAM_ID>

Available Demos

CommandSDKFile
just demo-web3 <ENC> <VOTE>@solana/web3.js (v1)e2e-voting-web3.ts
just demo-kit <ENC> <VOTE>@solana/kit (v2)e2e-voting-kit.ts
just demo-gill <ENC> <VOTE>gille2e-voting-gill.ts
just demo-rust <ENC> <VOTE>solana-sdk (Rust)e2e-voting-rust/
just demo <ENC> <VOTE>All four sequentially

What the Demo Does

  1. Create proposal — initializes encrypted yes/no tally counters (both start at 0)
  2. Cast 5 votes — 3 YES + 2 NO, each as an encrypted boolean via execute_graph
  3. Close proposal — authority locks voting
  4. Request decryption — asks executor to decrypt the tally ciphertexts
  5. Reveal results — reads decrypted values on-chain

Expected output:

═══ Results ═══

  → Total votes: 5
  → Yes votes: 3
  → No votes: 2

  Proposal PASSED (3 yes / 2 no)

How It Works

The demo script acts as a user client only — it encrypts values client-side, submits them via gRPC, and sends on-chain transactions. The pre-alpha executor running on devnet handles everything else:

  1. Script encrypts vote value and submits via gRPC CreateInput → executor verifies proof + creates ciphertext on-chain → returns account address
  2. Script sends cast_vote on voting program (CPI to execute_graph)
  3. Executor detects GraphExecuted event, evaluates the graph, and calls commit_ciphertext
  4. Script sends request_decryption on voting program (CPI to request_decryption)
  5. Executor detects DecryptionRequested event, decrypts, and calls respond_decryption

No authority keypair needed — the client never touches the executor’s keys.

Client SDK Usage

Rust:

#![allow(unused)]
fn main() {
let mut encrypt = EncryptClient::connect().await?;
let vote_ct = encrypt.create_input::<Bool>(true, &program_id, &network_key).await?;
}

TypeScript:

const encrypt = createEncryptClient(); // connects to pre-alpha endpoint
const { ciphertextIdentifiers } = await encrypt.createInput({
  chain: Chain.Solana,
  inputs: [{ ciphertextBytes: mockCiphertext(1n), fheType: 0 }],
  authorized: programId.toBytes(),
  networkEncryptionPublicKey: networkKey,
});

Reading Ciphertexts Off-Chain

Rust:

#![allow(unused)]
fn main() {
let result = client.read_ciphertext(&ct_pubkey, &reencryption_key, epoch, &keypair).await?;
// result.value = plaintext bytes (mock) or re-encrypted ct (production)
}

TypeScript:

import { encodeReadCiphertextMessage } from "@encrypt.xyz/pre-alpha-solana-client/grpc";

const msg = encodeReadCiphertextMessage(Chain.Solana, ctId, new Uint8Array(), 1n);
const result = await encrypt.readCiphertext({
  message: msg,
  signature: Buffer.alloc(64), // not needed for public ciphertexts
  signer: Buffer.alloc(32),
});

For private ciphertexts, sign the BCS message with your ed25519 keypair.

Troubleshooting

Airdrop failed — Solana devnet faucet may be rate-limited. Wait a few seconds and retry, or fund the keypair manually.

Transaction simulation failed — the program may have been wiped (pre-alpha data is reset periodically). Check the program ID is still deployed.

Timeout waiting for executor — the pre-alpha executor may be restarting. Wait a minute and retry.

Encrypted ACL: Overview

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

What We’re Building

An on-chain access control list where permissions are stored as encrypted bitmasks. Nobody – not validators, not explorers, not other users – can see what permissions are set. Grant, revoke, and check operations happen via FHE bitwise operations on encrypted u64 values.

Permission Model

Permissions are a 64-bit bitmask. Each bit represents a capability:

BitValuePermission
01READ
12WRITE
24EXECUTE
38ADMIN
Custom

All operations work on EUint64 (encrypted unsigned 64-bit integer).

Architecture

Admin                          Checker
  |                               |
  v                               v
grant_permission               check_permission
revoke_permission                 |
  |                               v
  v                         request_check_decryption
Encrypt CPI (FHE OR/AND)         |
  |                               v
  v                         reveal_check (nonzero = has permission)
Executor (off-chain FHE)
  |
  v
Commit result on-chain

Three FHE operations:

  • Grant: permissions | permission_bit (bitwise OR)
  • Revoke: permissions & revoke_mask (bitwise AND with inverse mask)
  • Check: permissions & permission_bit (bitwise AND; nonzero = permitted)

What You’ll Learn

  • Multiple FHE graphs in one program (grant, revoke, check)
  • The inverse mask pattern for revocation
  • Two state accounts (Resource + AccessCheck) with separate decryption flows
  • Admin-gated operations vs. public permission checks

Prerequisites

  • Rust (edition 2024)
  • Solana CLI + Platform Tools v1.54
  • Anchor framework

Encrypted ACL: Building the Program

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

1. Cargo.toml

[package]
name = "encrypted-acl-anchor"
edition.workspace = true

[dependencies]
encrypt-types = { workspace = true }
encrypt-dsl = { package = "encrypt-solana-dsl", path = "../../../program-sdk/dsl" }
encrypt-anchor = { workspace = true }
anchor-lang = { workspace = true }

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

Same three Encrypt crates as the counter example.

2. FHE Graphs

Three graphs, all operating on EUint64 bitmasks:

#![allow(unused)]
fn main() {
use encrypt_dsl::prelude::encrypt_fn;
use encrypt_types::encrypted::EUint64;

/// Grant: permissions = permissions | permission_bit
#[encrypt_fn]
fn grant_permission_graph(permissions: EUint64, permission_bit: EUint64) -> EUint64 {
    permissions | permission_bit
}

/// Revoke: permissions = permissions & revoke_mask
#[encrypt_fn]
fn revoke_permission_graph(permissions: EUint64, revoke_mask: EUint64) -> EUint64 {
    permissions & revoke_mask
}

/// Check: result = permissions & permission_bit
#[encrypt_fn]
fn check_permission_graph(permissions: EUint64, permission_bit: EUint64) -> EUint64 {
    permissions & permission_bit
}
}

Each #[encrypt_fn] generates:

  • A function returning the serialized graph bytes (e.g. grant_permission_graph() -> Vec<u8>)
  • A CPI trait method on EncryptContext (e.g. encrypt_ctx.grant_permission_graph(in1, in2, out))

All three graphs have 2 inputs and 1 output. The graph nodes are: Input(0), Input(1), Op(BitOr or BitAnd), Output.

3. State Accounts

Resource

#![allow(unused)]
fn main() {
#[account]
#[derive(InitSpace)]
pub struct Resource {
    pub admin: Pubkey,              // who can grant/revoke
    pub resource_id: [u8; 32],     // unique ID, PDA seed
    pub permissions: [u8; 32],      // ciphertext account pubkey
    pub pending_digest: [u8; 32],  // for permissions decryption
    pub revealed_permissions: u64,  // plaintext after admin decrypts
    pub bump: u8,
}
}

permissions stores the pubkey of a ciphertext account holding the encrypted bitmask. Only the admin can grant, revoke, or decrypt.

AccessCheck

#![allow(unused)]
fn main() {
#[account]
#[derive(InitSpace)]
pub struct AccessCheck {
    pub checker: Pubkey,            // who requested the check
    pub result_ct: [u8; 32],       // ciphertext account pubkey (AND result)
    pub pending_digest: [u8; 32],  // for check decryption
    pub revealed_result: u64,       // nonzero = has permission
    pub bump: u8,
}
}

Created per-check. The PDA is seeded by ["check", resource_id, checker_pubkey]. The result_ct holds the encrypted AND result. After decryption, revealed_result > 0 means the permission is granted.

4. Instructions Walkthrough

create_resource

#![allow(unused)]
fn main() {
pub fn create_resource(
    ctx: Context<CreateResource>,
    resource_id: [u8; 32],
    permissions_ct_id: [u8; 32],
) -> Result<()> {
    let res = &mut ctx.accounts.resource;
    res.admin = ctx.accounts.admin.key();
    res.resource_id = resource_id;
    res.permissions = permissions_ct_id;
    res.pending_digest = [0u8; 32];
    res.revealed_permissions = 0;
    res.bump = ctx.bumps.resource;
    Ok(())
}
}

The caller creates an encrypted zero off-chain and passes its pubkey as permissions_ct_id. The PDA seeds are ["resource", resource_id].

grant_permission

#![allow(unused)]
fn main() {
pub fn grant_permission(
    ctx: Context<GrantPermission>,
    cpi_authority_bump: u8,
) -> Result<()> {
    let res = &ctx.accounts.resource;
    require!(
        res.admin == ctx.accounts.admin.key(),
        AclError::Unauthorized
    );

    let encrypt_ctx = EncryptContext { /* ... */ };

    let permissions_ct = ctx.accounts.permissions_ct.to_account_info();
    let permission_bit_ct = ctx.accounts.permission_bit_ct.to_account_info();
    encrypt_ctx.grant_permission_graph(
        permissions_ct.clone(),  // input: current permissions
        permission_bit_ct,       // input: bit to grant
        permissions_ct,          // output: updated permissions (in-place)
    )?;

    Ok(())
}
}

Admin-only. The permission_bit_ct is an encrypted ciphertext containing the bit value to grant (e.g., encrypted 1 for READ, encrypted 2 for WRITE). The output overwrites the input – in-place update via permissions | bit.

revoke_permission

#![allow(unused)]
fn main() {
pub fn revoke_permission(
    ctx: Context<RevokePermission>,
    cpi_authority_bump: u8,
) -> Result<()> {
    let res = &ctx.accounts.resource;
    require!(
        res.admin == ctx.accounts.admin.key(),
        AclError::Unauthorized
    );

    let encrypt_ctx = EncryptContext { /* ... */ };

    let permissions_ct = ctx.accounts.permissions_ct.to_account_info();
    let revoke_mask_ct = ctx.accounts.revoke_mask_ct.to_account_info();
    encrypt_ctx.revoke_permission_graph(
        permissions_ct.clone(),  // input: current permissions
        revoke_mask_ct,          // input: inverse mask
        permissions_ct,          // output: updated permissions (in-place)
    )?;

    Ok(())
}
}

The Revoke Mask Pattern

To revoke a permission, the caller passes an inverse mask – all bits set except the one to revoke. For example:

  • Revoke READ (bit 0): mask = 0xFFFFFFFFFFFFFFFE
  • Revoke WRITE (bit 1): mask = 0xFFFFFFFFFFFFFFFD
  • Revoke EXECUTE (bit 2): mask = 0xFFFFFFFFFFFFFFFB

The FHE operation is permissions & mask, which clears exactly the target bit while preserving all others.

Why not use NOT + AND? Because FHE NOT on the permission bit would require an extra graph node and the caller already knows which bit to revoke. Passing the inverse mask is simpler and more gas-efficient.

check_permission

#![allow(unused)]
fn main() {
pub fn check_permission(
    ctx: Context<CheckPermission>,
    cpi_authority_bump: u8,
) -> Result<()> {
    let encrypt_ctx = EncryptContext { /* ... */ };

    let permissions_ct = ctx.accounts.permissions_ct.to_account_info();
    let permission_bit_ct = ctx.accounts.permission_bit_ct.to_account_info();
    let result_ct = ctx.accounts.result_ct.to_account_info();
    encrypt_ctx.check_permission_graph(
        permissions_ct,      // input: current permissions (read-only)
        permission_bit_ct,   // input: bit to check
        result_ct,           // output: AND result (separate account)
    )?;

    let chk = &mut ctx.accounts.access_check;
    chk.checker = ctx.accounts.checker.key();
    chk.result_ct = ctx.accounts.result_ct.key().to_bytes();
    chk.pending_digest = [0u8; 32];
    chk.revealed_result = 0;
    chk.bump = ctx.bumps.access_check;

    Ok(())
}
}

Unlike grant/revoke, check uses a separate output account (result_ct) so the permissions bitmask is not modified. Anyone can check – no admin requirement.

The AccessCheck PDA is created in the same instruction:

#![allow(unused)]
fn main() {
#[derive(Accounts)]
pub struct CheckPermission<'info> {
    pub resource: Account<'info, Resource>,
    #[account(
        init,
        payer = payer,
        space = 8 + AccessCheck::INIT_SPACE,
        seeds = [b"check", resource.resource_id.as_ref(), checker.key().as_ref()],
        bump,
    )]
    pub access_check: Account<'info, AccessCheck>,
    pub checker: Signer<'info>,
    // ... encrypt CPI accounts ...
}
}

request_check_decryption

#![allow(unused)]
fn main() {
pub fn request_check_decryption(
    ctx: Context<RequestCheckDecryption>,
    cpi_authority_bump: u8,
) -> Result<()> {
    let encrypt_ctx = EncryptContext { /* ... */ };

    let digest = encrypt_ctx.request_decryption(
        &ctx.accounts.request_acct.to_account_info(),
        &ctx.accounts.result_ciphertext.to_account_info(),
    )?;

    let chk = &mut ctx.accounts.access_check;
    chk.pending_digest = digest;

    Ok(())
}
}

Same digest pattern as the counter. The checker requests decryption of the AND result, stores the digest, then waits for the decryptor.

reveal_check

#![allow(unused)]
fn main() {
pub fn reveal_check(ctx: Context<RevealCheck>) -> Result<()> {
    let chk = &ctx.accounts.access_check;
    require!(
        chk.checker == ctx.accounts.checker.key(),
        AclError::Unauthorized
    );

    let expected_digest = &chk.pending_digest;
    let req_data = ctx.accounts.request_acct.try_borrow_data()?;
    use encrypt_types::encrypted::Uint64;
    let value = encrypt_anchor::accounts::read_decrypted_verified::<Uint64>(
        &req_data,
        expected_digest,
    )
    .map_err(|_| AclError::DecryptionNotComplete)?;

    let chk = &mut ctx.accounts.access_check;
    chk.revealed_result = *value;

    Ok(())
}
}

After reveal, revealed_result > 0 means the user has the checked permission. revealed_result == 0 means they don’t.

request_permissions_decryption / reveal_permissions

Admin-only decryption of the full permissions bitmask. Same pattern as request_check_decryption / reveal_check, but writes to resource.revealed_permissions.

#![allow(unused)]
fn main() {
pub fn request_permissions_decryption(
    ctx: Context<RequestPermissionsDecryption>,
    cpi_authority_bump: u8,
) -> Result<()> {
    let encrypt_ctx = EncryptContext { /* ... */ };

    let digest = encrypt_ctx.request_decryption(
        &ctx.accounts.request_acct.to_account_info(),
        &ctx.accounts.permissions_ciphertext.to_account_info(),
    )?;

    let res = &mut ctx.accounts.resource;
    res.pending_digest = digest;
    Ok(())
}

pub fn reveal_permissions(ctx: Context<RevealPermissions>) -> Result<()> {
    let res = &ctx.accounts.resource;
    require!(
        res.admin == ctx.accounts.admin.key(),
        AclError::Unauthorized
    );

    let expected_digest = &res.pending_digest;
    let req_data = ctx.accounts.request_acct.try_borrow_data()?;
    use encrypt_types::encrypted::Uint64;
    let value = encrypt_anchor::accounts::read_decrypted_verified::<Uint64>(
        &req_data,
        expected_digest,
    )
    .map_err(|_| AclError::DecryptionNotComplete)?;

    let res = &mut ctx.accounts.resource;
    res.revealed_permissions = *value;
    Ok(())
}
}

5. Instruction Summary

#InstructionWhoFHE OpModifies permissions?
1create_resourceadminnoneinitializes
2grant_permissionadminORyes (in-place)
3revoke_permissionadminANDyes (in-place)
4check_permissionanyoneANDno (separate output)
5request_check_decryptioncheckernoneno
6reveal_checkcheckernoneno
7request_permissions_decryptionadminnoneno
8reveal_permissionsadminnoneno

6. Error Codes

#![allow(unused)]
fn main() {
#[error_code]
pub enum AclError {
    #[msg("Unauthorized")]
    Unauthorized,
    #[msg("Decryption not complete")]
    DecryptionNotComplete,
}
}

Encrypted ACL: Testing

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

1. Unit Tests (Graph Logic)

Verify the FHE graphs produce correct bitwise results using a mock evaluator. No SBF build needed.

cargo test -p encrypted-acl-anchor --lib
#![allow(unused)]
fn main() {
#[test]
fn grant_single_permission() {
    let r = run_mock(
        grant_permission_graph,
        &[0, 1],
        &[FheType::EUint64, FheType::EUint64],
    );
    assert_eq!(r[0], 1, "granting READ (bit 0) to 0 should yield 1");
}

#[test]
fn grant_multiple_permissions() {
    let r = run_mock(
        grant_permission_graph,
        &[1, 2],
        &[FheType::EUint64, FheType::EUint64],
    );
    assert_eq!(r[0], 3, "granting WRITE (bit 1) to READ (1) should yield 3");
}

#[test]
fn revoke_permission() {
    let r = run_mock(
        revoke_permission_graph,
        &[3, 0xFFFFFFFFFFFFFFFE],
        &[FheType::EUint64, FheType::EUint64],
    );
    assert_eq!(r[0], 2, "revoking READ (bit 0) from 3 should yield 2");
}

#[test]
fn check_has_permission() {
    let r = run_mock(
        check_permission_graph,
        &[5, 1],
        &[FheType::EUint64, FheType::EUint64],
    );
    assert_eq!(r[0], 1, "checking READ on 5 (READ|EXECUTE) should yield 1");
}

#[test]
fn check_missing_permission() {
    let r = run_mock(
        check_permission_graph,
        &[4, 1],
        &[FheType::EUint64, FheType::EUint64],
    );
    assert_eq!(r[0], 0, "checking READ on 4 (EXECUTE only) should yield 0");
}

#[test]
fn graph_shapes() {
    let d = grant_permission_graph();
    let pg = parse_graph(&d).unwrap();
    assert_eq!(pg.header().num_inputs(), 2);
    assert_eq!(pg.header().num_outputs(), 1);
    // Same shape for revoke and check
}
}

2. LiteSVM Integration Tests (E2E)

Full lifecycle tests using LiteSVM with EncryptTestContext. Tests the complete flow: create resource, grant, revoke, check, and decrypt.

just build-sbf-examples
cargo test -p encrypted-acl-anchor --test litesvm

The test helpers abstract common patterns:

#![allow(unused)]
fn main() {
// Create a resource with encrypted-zero permissions
fn create_resource(ctx, program_id, admin) -> (resource_pda, permissions_ct, resource_id)

// Grant a permission: create encrypted bit, CPI, evaluate graph
fn do_grant(ctx, program_id, ..., permission_value: u128)

// Revoke a permission: create encrypted mask, CPI, evaluate graph
fn do_revoke(ctx, program_id, ..., revoke_mask: u128)

// Check a permission: create encrypted bit + result, CPI, evaluate, decrypt
fn do_check(ctx, program_id, ..., permission_value: u128) -> u128
}

Full lifecycle test:

#![allow(unused)]
fn main() {
#[test]
fn test_full_acl_lifecycle() {
    let mut ctx = EncryptTestContext::new_default();
    let (program_id, cpi_authority, cpi_bump) = setup_anchor_program(&mut ctx);
    let admin = ctx.new_funded_keypair();

    // 1. Create resource
    let (resource_pda, perm_ct, resource_id) =
        create_resource(&mut ctx, &program_id, &admin);

    // 2. Grant READ (bit 0 = 1)
    do_grant(&mut ctx, &program_id, &cpi_authority, cpi_bump, &admin,
        &resource_pda, &perm_ct, 1);

    // 3. Grant WRITE (bit 1 = 2)
    do_grant(&mut ctx, &program_id, &cpi_authority, cpi_bump, &admin,
        &resource_pda, &perm_ct, 2);

    // 4. Check READ -- should pass
    let checker1 = ctx.new_funded_keypair();
    let result = do_check(&mut ctx, &program_id, &cpi_authority, cpi_bump,
        &checker1, &resource_pda, &perm_ct, &resource_id, 1);
    assert_eq!(result, 1, "should have READ after granting");

    // 5. Revoke READ (mask = 0xFFFFFFFFFFFFFFFE)
    do_revoke(&mut ctx, &program_id, &cpi_authority, cpi_bump, &admin,
        &resource_pda, &perm_ct, 0xFFFFFFFFFFFFFFFE);

    // 6. Check READ -- should fail
    let checker2 = ctx.new_funded_keypair();
    let result = do_check(&mut ctx, &program_id, &cpi_authority, cpi_bump,
        &checker2, &resource_pda, &perm_ct, &resource_id, 1);
    assert_eq!(result, 0, "should NOT have READ after revoking");

    // 7. Decrypt permissions to verify = 2 (WRITE only)
    let perm_value = ctx.decrypt_from_store(&perm_ct);
    assert_eq!(perm_value, 2, "permissions should be 2 (WRITE only)");
}
}

3. Mollusk Instruction-Level Tests

Mollusk tests reveal_check and reveal_permissions in isolation. No CPI or Encrypt program needed – just raw account data and instruction processing.

just build-sbf-examples
cargo test -p encrypted-acl-anchor --test mollusk

Tests cover:

  • reveal_check succeeds with matching digest
  • reveal_check rejects wrong checker
  • reveal_check rejects digest mismatch
  • reveal_permissions succeeds with matching digest
  • reveal_permissions rejects wrong admin
#![allow(unused)]
fn main() {
#[test]
fn test_reveal_check_success() {
    let (mollusk, pid) = setup();
    let checker = Pubkey::new_unique();
    let digest = [0xABu8; 32];

    let check_data = build_anchor_access_check(&checker, &Pubkey::new_unique(), &digest, 0);
    let request_data = build_decryption_request_data(&digest, 1);

    let result = mollusk.process_instruction(/* ... */);
    assert!(result.program_result.is_ok());
    let revealed = u64::from_le_bytes(
        result.resulting_accounts[0].1.data[104..112].try_into().unwrap(),
    );
    assert_eq!(revealed, 1);
}

#[test]
fn test_reveal_permissions_success() {
    // Same pattern, checks resource.revealed_permissions at offset 136..144
}
}

4. Running All Tests

# Everything (build + all test types)
just test-examples

# Just LiteSVM e2e
just test-examples-litesvm

# Just Mollusk
just test-examples-mollusk

# Just program-test
just test-examples-program-test

# Single crate, all tests
cargo test -p encrypted-acl-anchor

Instruction Reference

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

All 22 instructions in the Encrypt Solana program. The first byte of instruction data is the discriminator.

Instruction Groups

GroupDisc RangeInstructions
Setup0initialize
Executor1–6create_input_ciphertext, create_plaintext_ciphertext, commit_ciphertext, execute_graph, register_graph, execute_registered_graph
Ownership7–9transfer_ciphertext, copy_ciphertext, make_public
Gateway10–12request_decryption, respond_decryption, close_decryption_request
Fees13–18create_deposit, top_up, withdraw, update_config_fees, reimburse, request_withdraw
Authority19–21add_authority, remove_authority, register_network_encryption_key
Event228emit_event

Setup

initialize (disc 0)

One-time program initialization. Creates the EncryptConfig and initial Authority PDAs.

Accounts:

#AccountWSDescription
0configyesnoEncryptConfig PDA (must be empty)
1authority_pdayesnoAuthority PDA (must be empty)
2initializernoyesInitial authority signer
3payeryesyesRent payer
4system_programnonoSystem program

Data (2 bytes): config_bump(1) | authority_bump(1)


Executor

create_input_ciphertext (disc 1)

Authority-driven: creates a verified ciphertext from off-chain encrypted data + ZK proof. Status = VERIFIED immediately.

Accounts:

#AccountWSDescription
0authority_pdanonoAuthority PDA
1signernoyesAuthority signer
2confignonoEncryptConfig
3deposityesnoEncryptDeposit (fee source)
4ciphertextyesnoNew Ciphertext account (must be empty)
5creatornonoWho gets authorized
6network_encryption_keynonoNetworkEncryptionKey PDA
7payeryesyesRent payer
8system_programnonoSystem program
9event_authoritynonoEvent authority PDA
10programnonoEncrypt program

Data (33 bytes): fhe_type(1) | ciphertext_digest(32)


create_plaintext_ciphertext (disc 2)

User-signed: creates a ciphertext from a plaintext value. The executor encrypts off-chain and commits later. Status = PENDING.

Supports both signer and CPI (program) callers. CPI path inserts cpi_authority at position 4.

Accounts (signer path):

#AccountWSDescription
0confignonoEncryptConfig
1deposityesnoEncryptDeposit
2ciphertextyesnoNew Ciphertext account (must be empty)
3creatornoyesSigner (gets authorized)
4network_encryption_keynonoNetworkEncryptionKey PDA
5payeryesyesRent payer
6system_programnonoSystem program
7event_authoritynonoEvent authority PDA
8programnonoEncrypt program

Accounts (CPI path): Same as above but cpi_authority is inserted at position 4, shifting positions 4–8 to 5–9.

Data (1+ bytes): fhe_type(1) | [plaintext_bytes(N)]


commit_ciphertext (disc 3)

Authority writes the ciphertext digest after off-chain FHE evaluation. Sets status from PENDING to VERIFIED.

Accounts:

#AccountWSDescription
0authority_pdanonoAuthority PDA
1signernoyesAuthority signer
2ciphertextyesnoCiphertext account
3event_authoritynonoEvent authority PDA
4programnonoEncrypt program

Data (32 bytes): ciphertext_digest(32)


execute_graph (disc 4)

Execute a computation graph. Creates/updates output ciphertext accounts. Emits GraphExecuted event.

Supports both signer and CPI callers. CPI path inserts cpi_authority at position 3.

Accounts (signer path):

#AccountWSDescription
0confignonoEncryptConfig
1deposityesnoEncryptDeposit
2callernoyesSigner
3network_encryption_keynonoNetworkEncryptionKey PDA
4payeryesyesRent payer
5event_authoritynonoEvent authority PDA
6programnonoEncrypt program
7..7+Ninput ciphertextsnonoInput ciphertext accounts
7+N..7+N+Moutput ciphertextsyesnoOutput ciphertext accounts

Accounts (CPI path): cpi_authority at position 3, remaining shifted by 1. Fixed accounts = 8 instead of 7.

Data: graph_data_len(2) | graph_data(N) | num_inputs(2)


register_graph (disc 5)

Register a reusable computation graph on-chain. Creates a RegisteredGraph PDA.

Accounts:

#AccountWSDescription
0graph_pdayesnoRegisteredGraph PDA (must be empty)
1registrarnoyesSigner
2payeryesyesRent payer
3system_programnonoSystem program

Data (35+ bytes): bump(1) | graph_hash(32) | graph_data_len(2) | graph_data(N)


execute_registered_graph (disc 6)

Execute a previously registered graph. Uses the on-chain graph data (no need to re-send).

Supports both signer and CPI callers.

Accounts (signer path):

#AccountWSDescription
0confignonoEncryptConfig
1deposityesnoEncryptDeposit
2graph_pdanonoRegisteredGraph PDA
3callernoyesSigner
4network_encryption_keynonoNetworkEncryptionKey PDA
5payeryesyesRent payer
6event_authoritynonoEvent authority PDA
7programnonoEncrypt program
8+remainingvariesnoInput + output ciphertexts

Accounts (CPI path): cpi_authority at position 4, fixed = 9.

Data (2 bytes): num_inputs(2)


Ownership

transfer_ciphertext (disc 7)

Transfer authorization to a new party by updating the authorized field.

Accounts (signer path):

#AccountWSDescription
0ciphertextyesnoCiphertext account
1current_authorizednoyesCurrent authorized signer
2new_authorizednonoNew authorized party

Accounts (CPI path): cpi_authority at position 2, new_authorized at position 3.

Data: none


copy_ciphertext (disc 8)

Create a copy of a ciphertext with a different authorized party.

Accounts (signer path):

#AccountWSDescription
0source_ciphertextnonoSource Ciphertext
1new_ciphertextyesnoNew Ciphertext account (must be empty)
2current_authorizednoyesCurrent authorized signer
3new_authorizednonoNew authorized party
4payeryesyesRent payer
5system_programnonoSystem program

Accounts (CPI path): cpi_authority at position 3, remaining shifted.

Data (1 byte): transient(1) (0 = permanent/rent-exempt, 1 = transient/0 lamports)


make_public (disc 9)

Set authorized to zero (public). Irreversible and idempotent.

Accounts (signer path):

#AccountWSDescription
0ciphertextyesnoCiphertext account
1callernoyesCurrent authorized signer

Accounts (CPI path): cpi_authority at position 2.

Data (32 bytes): ciphertext_id(32)


Gateway

request_decryption (disc 10)

Request decryption of a ciphertext. Creates a DecryptionRequest account and stores a digest snapshot.

Supports both signer and CPI callers.

Accounts (signer path):

#AccountWSDescription
0confignonoEncryptConfig
1deposityesnoEncryptDeposit
2request_acctyesnoDecryptionRequest account (must be empty)
3callernoyesSigner
4ciphertextnonoCiphertext to decrypt
5payeryesyesRent payer
6system_programnonoSystem program
7event_authoritynonoEvent authority PDA
8programnonoEncrypt program

Accounts (CPI path): cpi_authority at position 4, remaining shifted. Fixed = 10.

Data: none


respond_decryption (disc 11)

Authority writes the decrypted plaintext bytes into the DecryptionRequest account.

Accounts:

#AccountWSDescription
0authority_pdanonoAuthority PDA
1request_acctyesnoDecryptionRequest account
2signernoyesAuthority signer
3event_authoritynonoEvent authority PDA
4programnonoEncrypt program

Data (variable): plaintext bytes chunk to write


close_decryption_request (disc 12)

Close a decryption request and reclaim rent. Only the original requester can close.

Accounts (signer path):

#AccountWSDescription
0requestyesnoDecryptionRequest account
1callernoyesRequester signer
2destinationyesnoRent destination

Accounts (CPI path): cpi_authority at position 2, destination at position 3.

Data: none


Fees

create_deposit (disc 13)

Create an EncryptDeposit PDA for a user. Transfers initial ENC tokens and SOL gas.

Accounts:

#AccountWSDescription
0deposityesnoEncryptDeposit PDA (must be empty)
1confignonoEncryptConfig
2usernoyesDeposit owner
3payeryesyesRent payer
4user_atayesnoUser’s ENC token account
5vaultyesnoProgram’s ENC vault token account
6token_programnonoSPL Token program
7system_programnonoSystem program

Data (17 bytes): bump(1) | initial_enc_amount(8) | initial_gas_amount(8)


top_up (disc 14)

Add ENC tokens and/or SOL gas to an existing deposit.

Accounts:

#AccountWSDescription
0deposityesnoEncryptDeposit PDA
1confignonoEncryptConfig
2usernoyesDeposit owner
3user_atayesnoUser’s ENC token account
4vaultyesnoENC vault
5token_programnonoSPL Token program
6system_programnonoSystem program

Data (16 bytes): enc_amount(8) | gas_amount(8)


withdraw (disc 15)

Execute a pending withdrawal. Available when current_epoch >= withdrawal_epoch.

Accounts:

#AccountWSDescription
0deposityesnoEncryptDeposit PDA
1confignonoEncryptConfig
2usernoyesDeposit owner
3user_atayesnoUser’s ENC token account
4vaultyesnoENC vault
5vault_authoritynonoVault authority PDA
6token_programnonoSPL Token program

Data: none


update_config_fees (disc 16)

Authority updates the fee schedule in EncryptConfig.

Accounts:

#AccountWSDescription
0configyesnoEncryptConfig PDA
1authority_pdanonoAuthority PDA
2signernoyesAuthority signer

Data (58 bytes): enc_per_input(8) | enc_per_output(8) | max_enc_per_op(8) | max_ops_per_graph(2) | gas_base(8) | gas_per_input(8) | gas_per_output(8) | gas_per_byte(8)


reimburse (disc 17)

Authority credits back the per-op max-charge overcharge after computing actual costs.

Accounts:

#AccountWSDescription
0authority_pdanonoAuthority PDA
1signernoyesAuthority signer
2deposityesnoEncryptDeposit PDA

Data (16 bytes): enc_amount(8) | gas_amount(8)


request_withdraw (disc 18)

Set pending withdrawal amounts. Actual withdrawal available next epoch.

Accounts:

#AccountWSDescription
0deposityesnoEncryptDeposit PDA
1confignonoEncryptConfig
2usernoyesDeposit owner

Data (16 bytes): enc_amount(8) | gas_amount(8)


Authority

add_authority (disc 19)

Add a new authority. Must be signed by an existing authority.

Accounts:

#AccountWSDescription
0new_authyesnoNew Authority PDA (must be empty)
1existing_authnonoExisting Authority PDA
2signernoyesExisting authority signer
3payeryesyesRent payer
4system_programnonoSystem program

Data (33 bytes): bump(1) | new_pubkey(32)


remove_authority (disc 20)

Deactivate an authority.

Accounts:

#AccountWSDescription
0target_authyesnoAuthority PDA to deactivate
1signer_authnonoSigner’s Authority PDA
2signernoyesAuthority signer

Data: none


register_network_encryption_key (disc 21)

Register a new FHE network encryption public key.

Accounts:

#AccountWSDescription
0network_encryption_key_pdayesnoNetworkEncryptionKey PDA (must be empty)
1authority_pdanonoAuthority PDA
2signernoyesAuthority signer
3payeryesyesRent payer
4system_programnonoSystem program

Data (33 bytes): bump(1) | network_public_key(32)


Event

emit_event (disc 228)

Self-CPI event handler. Called internally by the Encrypt program to emit Anchor-compatible events. Not called by external programs.

Accounts:

#AccountWSDescription
0event_authoritynonoEvent authority PDA (must match)
1programnonoEncrypt program

Data: Event payload (prefixed with EVENT_IX_TAG_LE)

Account Reference

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

All 7 account types in the Encrypt Solana program. Each account starts with a 2-byte prefix: discriminator(1) | version(1), followed by the account data.

Account Discriminators

DiscriminatorAccount Type
1EncryptConfig
2Authority
3DecryptionRequest
4EncryptDeposit
5RegisteredGraph
6Ciphertext
7NetworkEncryptionKey

EncryptConfig (disc 1)

Program-wide configuration. PDA seeds: ["encrypt_config"].

OffsetFieldSizeDescription
0discriminator11
1version11
2current_epoch8Current epoch (LE u64)
10enc_per_input8ENC fee per input (LE u64)
18enc_per_output8ENC fee per output (LE u64)
26max_enc_per_op8Max ENC fee per operation (LE u64)
34max_ops_per_graph2Max operations per graph (LE u16)
36gas_base8Base SOL gas fee (LE u64)
44gas_per_input8SOL gas fee per input (LE u64)
52gas_per_output8SOL gas fee per output (LE u64)
60gas_per_byte8SOL gas fee per byte (LE u64)
68enc_mint32ENC SPL token mint address
100enc_vault32ENC vault token account address
132bump1PDA bump

Total: 2 + 131 = 133 bytes


Authority (disc 2)

Authorized operator (executor/decryptor). PDA seeds: ["authority", pubkey].

OffsetFieldSizeDescription
0discriminator12
1version11
2pubkey32Authority’s public key
34active1Active flag (0 = deactivated)
35bump1PDA bump

Total: 2 + 34 = 36 bytes


DecryptionRequest (disc 3)

Decryption request with result storage. Keypair account (not PDA) – no seed conflicts on multiple requests.

OffsetFieldSizeDescription
0discriminator13
1version11
2ciphertext32Ciphertext account pubkey
34ciphertext_digest32Digest snapshot at request time
66requester32Who requested decryption
98fhe_type1FHE type (determines result size)
99total_len4Expected result byte count (LE u32)
103bytes_written4Bytes written so far (LE u32)
107result dataNPlaintext bytes (N = byte_width of fhe_type)

Total: 2 + 105 + byte_width(fhe_type) bytes

Status is determined by bytes_written:

  • 0 = pending (decryptor has not responded)
  • == total_len = complete (result is ready)

EncryptDeposit (disc 4)

Fee deposit for a user. PDA seeds: ["encrypt_deposit", owner].

OffsetFieldSizeDescription
0discriminator14
1version11
2owner32Deposit owner pubkey
34enc_balance8ENC token balance (LE u64)
42gas_balance8SOL gas balance (LE u64)
50pending_enc_withdrawal8Pending ENC withdrawal (LE u64)
58pending_gas_withdrawal8Pending SOL withdrawal (LE u64)
66withdrawal_epoch8Epoch when withdrawal becomes available (LE u64)
74num_txs8Transaction counter (LE u64)
82bump1PDA bump

Total: 2 + 81 = 83 bytes


RegisteredGraph (disc 5)

A reusable computation graph stored on-chain. PDA seeds: ["registered_graph", graph_hash].

OffsetFieldSizeDescription
0discriminator15
1version11
2graph_hash32SHA-256 hash of graph data
34registrar32Who registered the graph
66num_inputs2Number of inputs (LE u16)
68num_outputs2Number of outputs (LE u16)
70num_ops2Number of operations (LE u16)
72finalized1Finalized flag
73bump1PDA bump
74graph_data_len2Actual graph data length (LE u16)
76graph_data4096Graph data (padded to max)

Total: 2 + 4170 = 4172 bytes

Maximum graph data: 4096 bytes.


Ciphertext (disc 6)

An encrypted value. Keypair account (not PDA) – the account pubkey IS the ciphertext identifier.

OffsetFieldSizeDescription
0discriminator16
1version11
2ciphertext_digest32Hash of the encrypted blob (zero until committed)
34authorized32Who can use this ([0; 32] = public)
66network_encryption_public_key32FHE key it was encrypted under
98fhe_type1Type discriminant (EBool=0, EUint64=4, etc.)
99status1Pending(0) or Verified(1)

Total: 2 + 98 = 100 bytes

Status values:

  • 0 = PENDING – waiting for executor to commit
  • 1 = VERIFIED – digest is valid, ciphertext can be used as input

NetworkEncryptionKey (disc 7)

FHE network public key. PDA seeds: ["network_encryption_key", key_bytes].

OffsetFieldSizeDescription
0discriminator17
1version11
2network_encryption_public_key32FHE network public key bytes
34active1Active flag (0 = deactivated)
35bump1PDA bump

Total: 2 + 34 = 36 bytes


Account Type Summary

AccountDiscTypeSize (bytes)PDA Seeds
EncryptConfig1PDA133["encrypt_config"]
Authority2PDA36["authority", pubkey]
DecryptionRequest3Keypair107 + N
EncryptDeposit4PDA83["encrypt_deposit", owner]
RegisteredGraph5PDA4172["registered_graph", graph_hash]
Ciphertext6Keypair100
NetworkEncryptionKey7PDA36["network_encryption_key", key_bytes]

Event Reference

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

The Encrypt program emits 5 event types via Anchor-compatible self-CPI. Each event is prefixed with EVENT_IX_TAG_LE (8 bytes, 0xe4a545ea51cb9a1d in LE) followed by a 1-byte event discriminator.

Event Discriminators

DiscriminatorEvent
0CiphertextCreated
1CiphertextCommitted
2GraphExecuted
3DecryptionRequested
4DecryptionResponded

CiphertextCreated (disc 0)

Emitted when a new ciphertext account is created (create_input_ciphertext or create_plaintext_ciphertext).

FieldSizeDescription
ciphertext32Ciphertext account pubkey
ciphertext_digest32Initial digest (zero for plaintext, real for input)
fhe_type1FHE type discriminant

Data size: 65 bytes

Used by the executor to detect new ciphertexts that need processing (plaintext ciphertexts need encryption and commit).


CiphertextCommitted (disc 1)

Emitted when an authority commits a ciphertext digest (commit_ciphertext), transitioning status from PENDING to VERIFIED.

FieldSizeDescription
ciphertext32Ciphertext account pubkey
ciphertext_digest32The committed digest

Data size: 64 bytes

Used by off-chain services to track when ciphertexts become usable as inputs.


GraphExecuted (disc 2)

Emitted when a computation graph is executed (execute_graph or execute_registered_graph). Output ciphertext accounts are created/updated with status=PENDING.

FieldSizeDescription
num_outputs2Number of output ciphertexts (LE u16)
num_inputs2Number of input ciphertexts (LE u16)
caller_program32Program that invoked execute_graph via CPI

Data size: 36 bytes

This is the primary event the executor listens for. Upon detection, the executor:

  1. Reads the graph data from the transaction
  2. Fetches the input ciphertext blobs
  3. Evaluates the computation graph using FHE
  4. Calls commit_ciphertext for each output

DecryptionRequested (disc 3)

Emitted when a decryption request is created (request_decryption).

FieldSizeDescription
ciphertext32Ciphertext account pubkey
requester32Who requested decryption

Data size: 64 bytes

The decryptor listens for this event and:

  1. Performs threshold MPC decryption (or mock decryption locally)
  2. Calls respond_decryption to write the plaintext result

DecryptionResponded (disc 4)

Emitted when the decryptor writes the plaintext result (respond_decryption).

FieldSizeDescription
ciphertext32Ciphertext account pubkey
requester32Who requested decryption

Data size: 64 bytes

Off-chain clients listen for this event to know when a decryption result is ready to read.


Event Wire Format

Each event is emitted as a self-CPI instruction with the following data layout:

EVENT_IX_TAG_LE(8) | event_discriminator(1) | event_data(N)

Total on-wire size per event = 9 + data size.

EventOn-Wire Size
CiphertextCreated9 + 65 = 74 bytes
CiphertextCommitted9 + 64 = 73 bytes
GraphExecuted9 + 36 = 45 bytes
DecryptionRequested9 + 64 = 73 bytes
DecryptionResponded9 + 64 = 73 bytes

Parsing Events

Events are emitted as inner instructions in the transaction. To parse them:

  1. Find inner instructions targeting the Encrypt program with discriminator 228 (EmitEvent)
  2. Skip the first 8 bytes (EVENT_IX_TAG_LE)
  3. Read the 1-byte event discriminator
  4. Deserialize the remaining bytes according to the event schema

The chains/solana/dev crate provides an event parser for use in tests and off-chain services.

Fee Model

Pre-Alpha Disclaimer: This is an early pre-alpha release for exploring the SDK and starting development only. There is no real encryption — all data is completely public and stored as plaintext on-chain. Do not submit any sensitive or real data. Encryption keys and the trust model are not final; do not rely on any encryption guarantees or key material until mainnet. All interfaces, APIs, and data formats are subject to change without notice. The Solana program and all on-chain data will be wiped periodically and everything will be deleted when we transition to Encrypt Alpha 1. This software is provided “as is” without warranty of any kind; use is entirely at your own risk and dWallet Labs assumes no liability for any damages arising from its use.

Encrypt uses a dual-token fee model: ENC (SPL token) for FHE computation costs and SOL gas for Solana transaction costs. Fees are charged upfront from the user’s EncryptDeposit account and partially reimbursed after actual costs are known.

Overview

User creates EncryptDeposit
    ├── ENC balance   (SPL token transfer to vault)
    └── Gas balance   (SOL transfer to deposit PDA)

execute_graph charges:
    ├── ENC: enc_per_input × total_inputs + enc_per_output × outputs + max_enc_per_op × ops
    └── Gas: gas_base + gas_per_input × inputs + gas_per_output × outputs

Authority reimburses (max_charge - actual_cost) after off-chain evaluation

Fee Parameters

Stored in the EncryptConfig account, updatable by authorities via update_config_fees:

ParameterSizeDescription
enc_per_inputu64ENC charged per input (encrypted + plaintext + constant)
enc_per_outputu64ENC charged per output ciphertext
max_enc_per_opu64Maximum ENC charged per FHE operation
max_ops_per_graphu16Maximum operations allowed per graph
gas_baseu64Base SOL gas fee per graph execution
gas_per_inputu64SOL gas fee per input
gas_per_outputu64SOL gas fee per output
gas_per_byteu64SOL gas fee per byte of graph data

ENC Fee Calculation

When execute_graph is called, the ENC fee is calculated as:

total_inputs = num_inputs + num_plaintext_inputs + num_constants
enc_fee = enc_per_input * total_inputs
        + enc_per_output * num_outputs
        + max_enc_per_op * num_ops

The max_enc_per_op is a worst-case charge. Different FHE operations have vastly different costs (e.g., multiplication is far more expensive than addition). Since the on-chain processor cannot determine actual costs without performing the FHE computation, it charges the maximum. The authority reimburses the difference after off-chain evaluation.

Gas Fee Calculation

SOL gas covers the Solana transaction costs:

gas_fee = gas_base
        + gas_per_input * num_inputs
        + gas_per_output * num_outputs

Deposit Lifecycle

1. Create Deposit

#![allow(unused)]
fn main() {
// Instruction: create_deposit (disc 13)
// Data: bump(1) | initial_enc_amount(8) | initial_gas_amount(8)
}

Creates an EncryptDeposit PDA for the user. Transfers initial_enc_amount ENC tokens from the user’s ATA to the program vault, and initial_gas_amount lamports as gas.

2. Top Up

#![allow(unused)]
fn main() {
// Instruction: top_up (disc 14)
// Data: enc_amount(8) | gas_amount(8)
}

Add more ENC and/or SOL to an existing deposit. Either amount can be zero.

3. Use (Automatic)

Every execute_graph, create_input_ciphertext, create_plaintext_ciphertext, and request_decryption call deducts fees from the deposit automatically. The deposit account is passed as a writable account in each of these instructions.

4. Reimburse

#![allow(unused)]
fn main() {
// Instruction: reimburse (disc 17)
// Data: enc_amount(8) | gas_amount(8)
}

After the executor evaluates a computation graph, it knows the actual per-operation costs. The authority calls reimburse to credit back the difference between max_enc_per_op * ops and the actual cost.

5. Request Withdraw

#![allow(unused)]
fn main() {
// Instruction: request_withdraw (disc 18)
// Data: enc_amount(8) | gas_amount(8)
}

Requests a withdrawal. Sets pending_enc_withdrawal, pending_gas_withdrawal, and withdrawal_epoch = current_epoch + 1. The withdrawal is delayed by one epoch to prevent front-running.

6. Withdraw

#![allow(unused)]
fn main() {
// Instruction: withdraw (disc 15)
// No data
}

Executes the pending withdrawal if current_epoch >= withdrawal_epoch. Actual amounts are capped at current balances (charges during the delay may have reduced them).

Registered Graph Fee Optimization

When using execute_registered_graph instead of execute_graph, the authority can compute exact per-operation costs because the graph is known ahead of time. This eliminates the max-charge gap and the need for reimbursement.

#![allow(unused)]
fn main() {
// Register a graph once
ctx.register_graph(graph_pda, bump, &graph_hash, &graph_data)?;

// Execute with exact fees (no max-charge overcharge)
ctx.execute_registered_graph(graph_pda, ix_data, remaining)?;
}

Fee Example

Given fee parameters:

  • enc_per_input = 100
  • enc_per_output = 50
  • max_enc_per_op = 200
  • gas_base = 5000
  • gas_per_input = 1000
  • gas_per_output = 500

For cast_vote_graph (3 inputs, 2 outputs, ~5 ops, 1 constant):

ENC upfront = 100 * (3 + 1) + 50 * 2 + 200 * 5 = 400 + 100 + 1000 = 1500
Gas         = 5000 + 1000 * 3 + 500 * 2 = 5000 + 3000 + 1000 = 9000

If actual per-op costs total 600 ENC (instead of max 1000), the authority reimburses 400 ENC.

EncryptDeposit Account Fields

FieldSizeDescription
owner32Deposit owner pubkey
enc_balance8Current ENC balance
gas_balance8Current SOL gas balance
pending_enc_withdrawal8Pending ENC withdrawal amount
pending_gas_withdrawal8Pending SOL gas withdrawal amount
withdrawal_epoch8Epoch when withdrawal is available
num_txs8Total transaction count
bump1PDA bump