Aleo for Agents is offline at present

blog·2026-08-24·10 min read

Project of the Week: Generate a Typed Aleo Client and Test It Against a Local Devnode

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_counts records how many receipts were issued to each recipient.
  • total_receipts stores 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:

  1. Build private_receipts.aleo with Leo.
  2. Generate a typed contract client from abi.json.
  3. Start an Aleo devnode and deploy the compiled program.
  4. 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.

bash
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.

bash
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.

json
{
  "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:

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.

json
{
  "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.

leo
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:

bash
cd ../private_receipts
leo build

Leo writes the compiled program and ABI under the program-specific build directory:

text
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.

bash
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.

bash
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:

typescript
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:

typescript
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/:

bash
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:

bash
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:

bash
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:

bash
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.

Sources