AI OCR Example
Building Zero-Knowledge Applications with Bonsol
This guide walks you through building applications that leverage zero-knowledge proofs on Solana using the Bonsol framework. We'll use an OCR (Optical Character Recognition) example that runs a machine learning model off-chain while maintaining cryptographic accountability on-chain.
How Bonsol Works
Bonsol enables you to move computation off-chain while keeping full transparency and verification on-chain:
Your app calls your Solana program with input data
Your program schedules a ZK job by calling the Bonsol program
Bonsol provers pick up the job, download your ZK program image, execute it, and generate a proof
Bonsol verifies the proof and calls your program back with the results
Your program processes the verified results on-chain
This architecture lets you run expensive computations (like ML inference, complex algorithms, or data processing) off-chain while maintaining the security guarantees of on-chain execution.
Architecture Overview

Step 1: Building the ZK Program
Your ZK program runs inside the RISC0 zkVM. It reads inputs, performs computation, and commits outputs that will be verified on-chain.
Basic Structure
use risc0_zkvm::guest::env;
fn main() {
// 1. Read input data
let mut input_data = [0u8; DATA_SIZE];
env::read_slice(&mut input_data);
// 2. Process the data
let result = process(input_data);
// 3. Commit the output (this becomes your verified result)
env::commit_slice(&result);
}
OCR Example
In our OCR example, we read a 28×28 pixel image (packed as bits), run it through a neural network, and output class probabilities:
fn main() {
// Read 98 bytes (28*28 pixels / 8 bits)
let mut image_bits = [0u8; IMAGE_BITS_SIZE];
env::read_slice(&mut image_bits);
// Unpack bits to floats
let mut image = vec![0.0_f32; IMAGE_SIZE];
for idx in 0..IMAGE_SIZE {
image[idx] = ((image_bits[idx / 8] >> (7 - idx % 8)) & 1) as f32;
}
// Run ML inference
let output: Vec<f32> = inference::Mnist::new().inference(&image).unwrap();
// Commit results to be verified on-chain
env::commit_slice(&output);
}
Key points:
Use
env::read_slice()to read input dataUse
env::commit_slice()to output results that will be available on-chainKeep your program focused and efficient while you're generating proofs of execution
1.1: Building and Deploying
# Build your ZK program
bonsol build
# Deploy to an image server
bonsol deploy url --url [your-image-server] --manifest-path manifest.json
For development, start a local image server:
cargo run -p local-zk-program-server
Your program gets a unique image ID (SHA256 hash) that you'll reference on-chain.
Step 2: Writing the Solana Program
Your Solana program acts as the orchestrator as it receives requests from users and schedules ZK jobs with Bonsol.
2.1: Scheduling a ZK Execution
use bonsol_interface::instructions::{execute_v1, CallbackConfig, ExecutionConfig, InputRef};
pub fn do_ocr(
ctx: Context<DoOcr>,
execution_id: String,
image_bits: [u8; 98],
tip: u64,
) -> Result<()> {
let slot = Clock::get()?.slot;
let ix = execute_v1(
ctx.accounts.payer.key, // Requester
ctx.accounts.payer.key, // Payer for execution
AI_IMAGE_ID, // Your ZK program's image ID
&execution_id, // Unique execution identifier
vec![InputRef::public(&image_bits)], // Input data
tip, // Tip for provers (in lamports)
slot + 100000000, // Expiration slot
ExecutionConfig {
forward_output: true, // Send output to callback
verify_input_hash: false,
input_hash: None,
},
Some(CallbackConfig {
program_id: crate::id(),
instruction_prefix: vec![245, 250, 10, 62, 218, 252, 239, 91], // Callback discriminator
extra_accounts: vec![
AccountMeta::new(ctx.accounts.ai_result.key(), false)
],
}),
None,
vec![], // Authorized provers (recommended)
)?;
invoke(&ix, &ctx.accounts.to_account_infos())?;
Ok(())
}
Key parameters:
execution_id: Unique identifier for this execution (used to derive PDAs)
image_id: The SHA256 hash of your deployed ZK program
InputRef::public(): Marks data as public input (there's also
InputRef::private())tip: Incentivizes provers to pick up your job quickly
CallbackConfig: Tells Bonsol how to call your program back with results
Authorized provers: A list of the public keys of provers that are authorized to run this job. It's recommended to use only trusted/performing provers.
2.2: Receiving Results via Callback
pub fn callback(ctx: Context<Callback>, result: [f32; 10]) -> Result<()> {
// Bonsol calls this after verification
ctx.accounts.ai_result.result = result;
Ok(())
}
#[derive(Accounts)]
pub struct Callback<'info> {
/// CHECK: Verified by Bonsol
pub execution_request: UncheckedAccount<'info>,
#[account(mut)]
pub ai_result: Account<'info, AiResult>,
}
Important: The callback is only invoked if the proof verification succeeds, in production you should verify that callback was called from our program.
2.3: Account Structure
#[derive(Accounts)]
#[instruction(execution_id: String)]
pub struct DoOcr<'info> {
#[account(mut)]
pub payer: Signer<'info>,
// Store results here
#[account(
init,
payer = payer,
space = 8 + 40,
seeds = [b"ai_result", execution_id.as_bytes(), payer.key().as_ref()],
bump
)]
pub ai_result: Account<'info, AiResult>,
/// CHECK: Bonsol execution request PDA
#[account(mut)]
pub execution_request: AccountInfo<'info>,
/// CHECK: Bonsol deployment account
pub deployment_account: AccountInfo<'info>,
#[account(executable, address = bonsol_interface::id())]
pub bonsol_program: AccountInfo<'info>,
pub system_program: Program<'info, System>,
}
Step 3: Building the Client
The client coordinates PDAs, submits transactions, and polls for results.
3.1: Deriving Program-Derived Addresses
To schedule a ZK job you typically need 3 accounts:
the execution request account: this is where the execution request will be stored, and later picked up by provers
the deployment account: this is the account that informs provers about your program’s deployment (where to download it from, what inputs it takes, etc.).
the result account: This is optional but you’ll typically want to store your ZK program’s output somewhere.
import { PublicKey } from "@solana/web3.js";
import keccak256 from "keccak256";
// Your result account
const [aiResultPda] = PublicKey.findProgramAddressSync(
[Buffer.from("ai_result"), Buffer.from(executionId), payer.toBuffer()],
AI_PROGRAM_ID
);
// Bonsol execution request account
const [executionRequestPda] = PublicKey.findProgramAddressSync(
[Buffer.from("execution"), payer.toBuffer(), Buffer.from(executionId)],
BONSOL_PROGRAM_ID
);
// Bonsol deployment account (from image ID)
const deriveDeploymentAccount = (imageIdSha256Hex: string): PublicKey => {
const hash = keccak256(Buffer.from(imageIdSha256Hex));
const [deploymentPda] = PublicKey.findProgramAddressSync(
[Buffer.from("deployment"), hash],
BONSOL_PROGRAM_ID
);
return deploymentPda;
};
3.2: Submitting an Execution Request
After all the accounts are derived, we schedule a new job calling our program (which in turn will call Bonsol’s program).
const executionId = `ocr_${Date.now()}_${wallet.publicKey.toString().substring(0, 8)}`;
const tx = await program.methods
.doOcr(executionId, imageBits, new anchor.BN(tipLamports))
.accounts({
payer: wallet.publicKey,
aiResult: aiResultPda,
executionRequest: executionRequestPda,
deploymentAccount: deriveDeploymentAccount(IMAGE_ID),
})
.rpc();
3.3: Polling for Results
Since ZK proof generation happens off-chain asynchronously, you need to poll for results:
const pollForAiResult = async (
connection: Connection,
wallet: AnchorWallet,
executionId: string,
maxDurationSeconds: number = 3600,
pollIntervalSeconds: number = 10
): Promise<{ found: boolean; result?: number[] }> => {
const startTime = Date.now();
while (Date.now() - startTime < maxDurationSeconds * 1000) {
try {
const account = await program.account.aiResult.fetchNullable(aiResultPda);
if (account && account.result.some(v => v !== 0)) {
return { found: true, result: account.result };
}
} catch (e) {
// Account not initialized yet
}
await new Promise(resolve => setTimeout(resolve, pollIntervalSeconds * 1000));
}
return { found: false };
};
Complete Flow Example
The entire flow includes the following steps:
User draws digit on canvas → Exports as 98-byte bit array
Client calls
doOcr()→ Creates execution request on-chainSolana program calls Bonsol → Schedules ZK job with image data
Off-chain prover:
Picks up job from Bonsol program
Downloads ZK program image
Executes ML inference in zkVM
Generates proof
Submits proof + output to Bonsol
Bonsol verifies proof → Calls your program's callback
Callback stores results → Writes to
ai_resultaccountClient polls and reads → Displays recognized digit

Best Practices
Execution IDs: Use unique, descriptive identifiers. Include timestamps and user info to avoid collisions:
const executionId = `${operation}_${Date.now()}_${wallet.publicKey.toString().slice(0, 8)}`;
Tips: Higher tips incentivize faster proof generation. Balance cost vs. speed based on your use case.
Input Data: Keep inputs small when possible, as they’re committed to on-chain transactions.
For large data, consider using hashes and storing data off-chain.
Error Handling: Always handle cases where proofs time out or fail. Provide feedback to users.
Testing: Use devnet for development. Start a local image server for rapid iteration.
Summary
Bonsol lets you build powerful Solana applications that leverage off-chain computation while maintaining on-chain verification:
Write ZK programs that perform complex computations
Deploy them to accessible image servers
Schedule executions from your Solana program
Receive verified results via callbacks
Build UIs that poll for results asynchronously
This pattern works for ML inference, complex algorithms, data processing, and any computation too expensive for on-chain execution but requiring cryptographic accountability.
For now, our proof of concept demo only works with numbers since training was done on the MNIST dataset with numbers only. To expand into other use cases like verifying characters, you simply have to train the AI model with a dataset such as the EMNIST dataset which includes letters, both lowercase and uppercase.
Last updated