Introduction
Repeated proving has always had an annoying tax. Before the useful work begins, your client must parse the target program, resolve every import, validate the resulting graph, and turn each source file into the internal form used by the prover. Run one execution and the tax is easy to ignore. Run fifty and it starts growling at you.
Provable SDK 0.11.6 adds prepared program contexts for exactly that case. A context holds a validated program together with its resolved dependencies, so later proving calls can reuse the same setup. The larger the program graph, the more a context saves, because that preparation is otherwise repeated for each request.
We will build a Node.js batch client around two small Leo programs. The client prepares each program once, proves several executions against the same context, and reuses it while estimating an execution paid with a private credits record.
One boundary matters: prepared contexts are process-local objects. Do not serialize one to disk or treat it as a replacement for proving-key storage. Build it when your worker starts, keep it alive for the worker's lifetime, and replace it whenever the program source or any import changes.
What we're building
Our Leo project quotes a reward by adding a base amount and a bonus. reward_math.aleo offers the shared arithmetic that any program can call; batch_rewards.aleo holds the quoting function the client proves, together with the bonus cap the application applies.
The example is deliberately small. Proving-context reuse is an SDK concern, so a large circuit would bury the part we are trying to inspect. The same client shape works for a token application whose main program imports a registry, an allowlist, or another application program, where you pass those compiled sources alongside the root.
The finished client will:
- Load the compiled Aleo Instructions for both programs.
- Create a prepared program context for each during startup.
- Reuse them across several proved executions.
- Estimate a private execution fee from an existing prepared context.
No network broadcast occurs. You can inspect proofs and fee estimates without accidentally sending our little lion onto testnet.
Prerequisites
Use Leo 4.4 or newer and Node.js 20 or newer. The TypeScript package is pinned to Provable SDK 0.11.6 because earlier releases do not expose the prepared-context API used below.
Check the tools first:
leo --version
node --version
npm --version
Create the workspace:
mkdir reusable-proving-context
cd reusable-proving-context
leo new reward_math
leo new batch_rewards
mkdir client
The final layout is:
reusable-proving-context/
reward_math/
program.json
src/main.leo
batch_rewards/
program.json
src/main.leo
client/
package.json
tsconfig.json
src/index.ts
Step-by-step
Write the shared program
Replace reward_math/src/main.leo with the complete program below:
program reward_math.aleo {
@noupgrade
constructor() {}
fn add_bonus(base: u64, bonus: u64) -> u64 {
return base + bonus;
}
}
add_bonus uses checked u64 arithmetic. A sum larger than the type permits fails instead of wrapping to zero. That is the behavior I want for rewards; silent wrapping would turn a configuration mistake into a very odd payout.
The constructor declares the deployment policy. @noupgrade is sensible for a shared arithmetic program because callers can know that its code will not change after deployment.
Use this full reward_math/program.json manifest:
{
"program": "reward_math.aleo",
"version": "0.1.0",
"description": "Shared reward arithmetic for the prepared-context tutorial",
"license": "MIT",
"dependencies": {}
}
Build the dependency:
cd reward_math
leo build
leo run add_bonus 100u64 25u64
cd ..
The run should return 125u64.
Write the quoting program
Replace batch_rewards/src/main.leo with:
program batch_rewards.aleo {
@noupgrade
constructor() {}
fn quote_reward(base: u64, bonus: u64) -> u64 {
let capped: u64 = bonus > 1000u64 ? 1000u64 : bonus;
return base + capped;
}
}
quote_reward is intentionally thin. It applies the application's bonus cap and returns the total, which keeps the circuit small enough that the client behaviour we care about stays visible. A larger version would delegate the arithmetic to reward_math.aleo with a call like reward_math.aleo::add_bonus(base, capped); the compiled program would then carry that import, and the client would have to supply the imported source as well.
There is no final { } block here because quoting a reward does not modify public state. Adding storage merely to demonstrate syntax would make the program worse. If a later version records a public aggregate, put the storage write in the function's final { } block rather than smuggling public state into private execution logic.
Use this full batch_rewards/program.json manifest:
{
"program": "batch_rewards.aleo",
"version": "0.1.0",
"description": "Batch reward quoting for the prepared-context tutorial",
"license": "MIT",
"dependencies": {}
}
Build and run the quoting program:
cd batch_rewards
leo build
leo run quote_reward 100u64 25u64
leo run quote_reward 900u64 75u64
cd ..
Those commands should return 125u64 and 975u64. More importantly, leo build confirms that the constructor declarations, function signatures, and manifests agree before TypeScript enters the room.
Create the client
Initialize the Node.js project:
cd client
npm init -y
npm install @provablehq/sdk@0.11.6
npm install --save-dev typescript tsx @types/node
mkdir src
Replace client/package.json with:
{
"name": "reusable-proving-context-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit",
"start": "tsx src/index.ts"
},
"dependencies": {
"@provablehq/sdk": "0.11.6"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
}
}
Add client/tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
Prepare each program once
Create client/src/index.ts:
import { readFile } from "node:fs/promises";
import {
Account,
AleoNetworkClient,
ProgramManager,
} from "@provablehq/sdk/testnet.js";
const endpoint = "https://api.explorer.provable.com/v1/testnet";
const quotePath = new URL(
"../../batch_rewards/build/batch_rewards/batch_rewards.aleo",
import.meta.url,
);
const mathPath = new URL(
"../../reward_math/build/reward_math/reward_math.aleo",
import.meta.url,
);
const quoteProgram = await readFile(quotePath, "utf8");
const rewardMath = await readFile(mathPath, "utf8");
const account = new Account();
const networkClient = new AleoNetworkClient(endpoint);
const manager = new ProgramManager(endpoint, undefined, undefined);
manager.setAccount(account);
manager.setNetworkClient(networkClient);
const preparedQuote = await manager.prepareProgram(quoteProgram, {});
const preparedMath = await manager.prepareProgram(rewardMath, {});
const jobs = [
["100u64", "25u64"],
["900u64", "75u64"],
["4000u64", "250u64"],
["9999u64", "1u64"],
] as const;
for (const inputs of jobs) {
const response = await manager.runPrepared(
preparedQuote,
"quote_reward",
[...inputs],
true,
);
console.log({
inputs,
outputs: response.getOutputs(),
hasExecution: response.getExecution() !== undefined,
});
}
const mathResponse = await manager.runPrepared(
preparedMath,
"add_bonus",
["41u64", "1u64"],
true,
);
console.log({ addBonus: mathResponse.getOutputs() });
const feeRecord = process.env.FEE_RECORD;
if (feeRecord) {
const fee = await manager.estimateExecutionFeePrepared({
preparedProgram: preparedQuote,
functionName: "quote_reward",
inputs: ["100u64", "25u64"],
privateFee: true,
feeRecord,
});
console.log({ privateFeeMicrocredits: fee });
} else {
console.log("Set FEE_RECORD to run the private fee estimate.");
}
The second argument of prepareProgram is an imports object keyed by program ID, not by a filesystem path. Neither program here calls the other, so both pass an empty object. Once batch_rewards.aleo does import a program, add that compiled source under its program ID, and include transitive imports too; a prepared context validates the whole graph rather than fetching missing pieces halfway through a proof.
prepareProgram is the expensive setup boundary. It parses the root source, checks the supplied programs, resolves calls, and materializes the import collection used by later requests. Keep the returned object near your worker or service instance, not inside a per-request handler.
runPrepared receives that object directly. Passing true asks the SDK to prove the execution rather than returning only an evaluated result. The loop changes inputs while the prepared program stays fixed, which is the exact workload prepared contexts are meant to improve.
Estimate a private fee
Private fee authorization consumes a credits.aleo record. Put a plaintext testnet record in an environment variable rather than committing it:
export FEE_RECORD='{
owner: aleo1youraddress.private,
microcredits: 5000000u64.private,
_nonce: 1group.public
}'
Use a real unspent record owned by the account configured in the client. The placeholder above explains the shape but cannot authorize a fee.
The prepared fee method reuses the same context as the proving loop. That detail matters. A fee estimate must model the execution the client will actually prove; rebuilding the context or omitting an imported source wastes setup time and produces an estimate for the wrong execution shape.
Run the client:
npm run build
npm start
Never log a production fee record. Record plaintext contains spendable state, and dumping it into CI output is a surprisingly efficient way to fund somebody else's experiments.
Cache with the right key
A long-running prover should keep more than one context if it supports several program versions. Key the cache with the root source plus every imported source, not only the root program ID.
A practical cache key is a digest over sorted pairs of program ID and compiled source. Any import change then creates a different context. Program IDs alone are unsafe during development because the local source can change while the name stays put.
Keep cache eviction boring. An LRU limit based on measured memory use is better than an immortal map, since prepared programs retain parsed structures and proving material. Start small and inspect the process under realistic concurrency.
Testing
First verify the Leo side independently:
cd reward_math
leo build
leo run add_bonus 41u64 1u64
cd ../batch_rewards
leo build
leo run quote_reward 41u64 1u64
cd ../client
npm run build
npm start
Both Leo runs should return 42u64. The TypeScript client should print four output arrays with an execution for each job, followed by the add_bonus result from the second context.
Now test failure behavior. Change one batch input to 18446744073709551615u64 plus 1u64. The call must fail because checked u64 addition overflows. Reusing a context reuses setup, not results or witnesses, so every input still receives a fresh execution and proof.
For a quick reuse check, log immediately before each prepareProgram call. You should see those two lines once each while the proving loop completes four times. Avoid timing only the first proof because proving-key synthesis or cache warmup can dominate it. Compare a prepared batch against a cold path over enough executions to match your application.
Also test a broken program. Truncate the compiled source before it reaches prepareProgram and confirm that preparation fails before the job loop starts. Early failure is a feature: a worker with a broken program graph should refuse traffic rather than discover the problem after accepting a proving request.
What's next
Move preparation into a worker startup hook and expose a narrow job interface containing the function name and typed inputs. Browser applications should keep proving off the UI thread; the worker can own the prepared object for as long as the page is open.
If you are building the browser side first, the earlier browser-first private transfer tutorial covers SDK initialization and multithreaded WASM. Prepared contexts fit naturally into that design: initialize the pool once, prepare each program graph once, then send execution jobs to the worker.
Large import trees deserve stricter tests. The stub-ordering analysis explains why imported call structure is compiler territory, especially when public finalization crosses program boundaries. Pin compiler and SDK versions together, then rebuild every compiled source after either one changes.
I like this API because it gives repeated proving an honest lifecycle. Startup work happens at startup. Request work happens per request. That sounds obvious, yet proving clients have spent years mixing the two and wondering why a four-import application feels heavy after the first demo. The lion approves.
Sources
- Provable SDK PR #1382 - Add reusable prepared program contexts
- Provable SDK repository
- Provable SDK Getting Started
- Executing Programs with the Provable SDK
- Project of the Week: Build a Browser-First Private Transfer App with create-leo-app
- Deep Dive: Why Leo's Stub-Ordering Bug Exposes Aleo's Real Composability Boundary