Introduction
A compiling Leo program is only half an application. The other half has to deploy it, encode arguments in ABI order, submit transactions, wait for finalization, and read the resulting state without quietly turning every value into an untyped string.
Veil now covers that second half. @provablehq/veil-codegen turns the ABI emitted by leo build into TypeScript bindings. @provablehq/veil-leo runs the Leo toolchain from Node, while @provablehq/veil-aleo-devnode starts a disposable local node for integration tests.
I like this split. Leo remains the source of truth for program behavior. The generated client owns the boundary between Leo values and TypeScript values. Your test owns the workflow.
We will still encounter one sharp edge: a Leo ABI records function input types and their order, but it does not retain source parameter names. Generated calls therefore use ordered input tuples. That is less pretty than a named object, yet much safer than scattering strings such as "250u64" around a test suite.
If you want more background on local-node testing, read Build a Quota Ledger on Aleo with leo devnode. The ABI-driven transaction form tutorial explains why ABI input order matters.
What we're building
Our program issues private Receipt records. Each receipt contains an amount and a memo hash that only the record owner can inspect. Public state keeps two coarse counters:
receipt_countsrecords how many receipts were issued to each recipient.total_receiptsstores the total number issued by the program.
The recipient is public because the final block uses it as a public mapping key. The amount and memo hash never enter finalization, so they stay inside the private execution and encrypted record.
That privacy boundary is deliberate. Anyone can learn that an address received two receipts. They cannot read either amount or memo hash from the mapping. If recipient privacy matters too, remove the per-address mapping and keep only an aggregate counter. You cannot write an address into public state and then call the address private. The chain has a good memory, even when the lion writing the tutorial does not.
The completed flow is:
- Build
private_receipts.aleowith Leo. - Generate a typed contract client from
abi.json. - Start an Aleo devnode and deploy the compiled program.
- Issue a receipt, wait for finalization, and assert both public state values.
Prerequisites
Use a current Leo 4 release. The source below depends on fn, inline final {} blocks, singleton storage, and mandatory constructors.
leo --version
node --version
npm --version
Node 20 or later is a sensible baseline for the test project. Create the workspace next to the Leo project rather than mixing generated TypeScript into build/, which Leo may replace.
mkdir typed-receipts
cd typed-receipts
leo new private_receipts
mkdir client
cd client
npm init -y
npm install @provablehq/veil-core @provablehq/veil-codegen @provablehq/veil-leo @provablehq/veil-aleo-devnode
npm install --save-dev typescript vitest @types/node
Replace client/package.json with the following file. Using latest is convenient for a tutorial, but pin resolved versions in a real repository. Code generation and its runtime package should move together.
{
"name": "private-receipts-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"codegen": "veil-codegen --abi ../private_receipts/build/private_receipts/abi.json --out ./src/generated/private-receipts.ts",
"test": "vitest run"
},
"dependencies": {
"@provablehq/veil-aleo-devnode": "latest",
"@provablehq/veil-codegen": "latest",
"@provablehq/veil-core": "latest",
"@provablehq/veil-leo": "latest"
},
"devDependencies": {
"@types/node": "latest",
"typescript": "latest",
"vitest": "latest"
}
}
Add client/tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node", "vitest/globals"]
},
"include": ["src", "test"]
}
Step-by-step
1. Define the manifest
Open private_receipts/program.json and replace its contents. The program identifier must match the declaration in src/main.leo.
{
"program": "private_receipts.aleo",
"version": "0.1.0",
"description": "Issue private receipt records while tracking public receipt counts",
"license": "MIT"
}
The manifest looks boring because it is boring. Good. Program identity is the part that matters here, and clever manifests do not make safer contracts.
2. Write the Leo program
Replace private_receipts/src/main.leo with the complete program below.
program private_receipts.aleo {
record Receipt {
owner: address,
issuer: address,
amount: u64,
memo_hash: field,
}
mapping receipt_counts: address => u64;
storage total_receipts: u64;
fn issue(
public recipient: address,
amount: u64,
memo_hash: field,
) -> (Receipt, Final) {
let caller: address = std::ctx::caller();
let receipt: Receipt = Receipt {
owner: recipient,
issuer: caller,
amount,
memo_hash,
};
return (receipt, final {
let current_count: u64 = Mapping::get_or_use(
receipt_counts,
recipient,
0u64,
);
Mapping::set(
receipt_counts,
recipient,
current_count + 1u64,
);
let current_total: u64 = total_receipts.unwrap_or(0u64);
total_receipts = current_total + 1u64;
});
}
@noupgrade
constructor() {}
}
Receipt is private state. Passing a record into another function would consume it, while returning a new record would create a fresh ciphertext for its owner. Every record declares owner: address; Leo and the Aleo VM use that field to decide who may decrypt and spend the record.
receipt_counts is public keyed state. Mapping::get_or_use handles a recipient with no existing entry, then Mapping::set writes the incremented value during finalization.
total_receipts is singleton storage. New storage variables are uninitialized, so the code reads it with unwrap_or(0u64) before the first write. Initializing it in an @custom constructor would also work, though that introduces upgrade-edition logic we do not need for a non-upgradeable sample.
The entry function returns (Receipt, Final). Private execution constructs the record. The inline final block updates public state atomically after the execution is accepted.
Build it from the Leo project directory:
cd ../private_receipts
leo build
Leo writes the compiled program and ABI under the program-specific build directory:
build/private_receipts/private_receipts.aleo
build/private_receipts/abi.json
Run the function locally as a quick circuit check. Set RECIPIENT to a valid Aleo address owned by your development account.
export RECIPIENT="aleo1replace_with_a_valid_development_address"
leo run issue "$RECIPIENT" 250u64 123field
leo run proves that the function compiles and executes. It does not replace the integration test because we still need to check deployment, transaction submission, finalization, and network reads.
3. Generate the client
Return to the TypeScript directory and run code generation against the emitted ABI.
cd ../client
mkdir -p src/generated test
npm run codegen
Do not edit src/generated/private-receipts.ts. Regenerate it after every ABI change and commit the result if your build or release process needs reproducible clients.
Veil maps Leo integers to JavaScript bigint, addresses to typed address strings, and function inputs to ABI-ordered tuples. A call shaped like this is checked by TypeScript before the test reaches a node:
await receipts.write.issue({
inputs: [recipient, 250n, 123n],
})
Changing 250n to "250u64" should fail type checking. Supplying two inputs should also fail. The generated client performs the Aleo encoding at the boundary, which is the whole point of using it.
The tuple order is [recipient, amount, memo_hash]. Leo's ABI keeps those types in order but omits the original parameter names. Codegen cannot recover information the compiler did not emit.
4. Add the integration test
Create client/test/private-receipts.test.ts:
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, expect, test } from 'vitest'
import { createDevnode } from '@provablehq/veil-aleo-devnode'
import { createLeo } from '@provablehq/veil-leo'
import { createPrivateReceiptsContract } from '../src/generated/private-receipts.js'
const here = dirname(fileURLToPath(import.meta.url))
const leoRoot = resolve(here, '../../private_receipts')
const devnode = createDevnode({
port: 3030,
skipExecutionProof: true,
skipDeployCertificate: true,
})
const leo = createLeo({ cwd: leoRoot })
beforeAll(async () => {
await devnode.start()
await leo.build()
await leo.deploy({
endpoint: devnode.endpoint,
privateKey: devnode.privateKey,
skipDeployCertificate: true,
})
}, 120_000)
afterAll(async () => {
await devnode.stop()
})
test('issues a private receipt and finalizes public counters', async () => {
const recipient = devnode.address
const receipts = createPrivateReceiptsContract({
publicClient: devnode.publicClient,
walletClient: devnode.walletClient,
})
const transactionId = await receipts.write.issue({
inputs: [recipient, 250n, 123n],
skipExecutionProof: true,
})
await devnode.waitForTransaction({ id: transactionId })
const recipientCount = await receipts.read.receipt_counts({
key: recipient,
})
const total = await receipts.read.total_receipts()
expect(recipientCount).toBe(1n)
expect(total).toBe(1n)
})
The devnode owns a funded local development account, so the test does not need faucet credits or a production key. Keep that boundary. Copying a funded private key into an integration-test environment is a poor trade even when the machine is yours.
createLeo runs leo build and leo deploy without shell-command parsing in the test. Errors include the command context and process output, which is far nicer than debugging a rejected promise from a homemade exec wrapper.
createPrivateReceiptsContract comes from the generated file. Its issue input tuple and mapping key type are derived from abi.json. The test contains ordinary TypeScript values rather than Aleo literals.
Both proof-skipping options are local-test settings. skipExecutionProof shortens execution cycles. skipDeployCertificate also bypasses the deployment circuit-limit check, so a successful local deployment does not prove that Testnet will accept the program. Run leo synthesize --local before a public deployment.
Testing
Run the generated-client test from client/:
npm run codegen
npm test
A passing run proves more than leo run alone:
- Leo built the program and emitted an ABI.
- Veil deployed the compiled program to a live local node.
- The generated client encoded the ordered inputs and submitted
issue. - Finalization changed both the mapping and singleton storage.
Break the test on purpose once. Change 250n to 250 and run TypeScript checking:
npx tsc --noEmit
The generated binding should reject number because a JavaScript number cannot represent every u64 exactly. Restore 250n afterward.
Then change the expected recipient count to 2n and run the test again:
npm test
That failure checks a different boundary. TypeScript can verify the call shape, but only the devnode can show what finalization wrote.
If deployment hangs, confirm that port 3030 is free and that another leo devnode process is not running. If code generation cannot find the ABI, rerun leo build and inspect build/private_receipts/abi.json. Older Leo releases used different output layouts, which is another reason to check leo --version before chasing path errors.
What's next
Add a second entry function that consumes a Receipt and returns a replacement owned by another address. The generated binding should then require a complete record plaintext for the consumed input, including its nonce data supplied by the wallet or record provider. Do not model a record as an editable TypeScript object assembled by a form.
A second useful test is privacy-focused. Issue two receipts with different amounts, verify that the public counters rise, then inspect the transaction and mapping responses to confirm neither amount appears in public state. Such a test will not prove every privacy property, but it catches accidental movement of private values into a final block.
Before Testnet, run the full local checks without the deployment-limit bypass:
cd ../private_receipts
leo build
leo synthesize --local
The generated client should also be rebuilt in CI and checked for an empty Git diff. That catches the common mistake where a Leo function changes but the committed TypeScript client still describes yesterday's ABI.
For agent-controlled development, pair this setup with the restricted workflow in Build a Guardrailed Aleo Coding Agent with AleoFlow MCP. Give the agent build and local-test access. Keep funded signing keys somewhere else.
The practical payoff is small and useful: change Leo, build, regenerate, test. Type errors catch ABI drift before a transaction starts, while the devnode catches the state mistakes a type checker cannot see.