blog·2026-03-16·8 min read

Project of the Week: Build a Test-First Allowance Ledger in Leo

Introduction

Leo finally has a testing story that feels like a real developer tool, not a dare.

Compiled tests are now good for regular transitions and helper logic. Script tests cover the awkward part, async flows that touch mappings and return Futures. That split makes a lot of sense once you stop pretending stateful code should be tested the same way as pure arithmetic.

Aleo is still running weekly Developer Office Hours in Discord, with the March 18 slot already back on the calendar after a one-week cancellation. That tells me the demand is real. People want working examples they can copy, break, fix, and learn from.

One quick sanity check before we type. The public Leo docs for testing and async state still use transition, async transition, and paired async function blocks. If you have seen fn, inline final {} blocks, or mandatory constructors floating around in random snippets, do not paste them into a project you expect to build today. Green tests beat fantasy syntax every time.

If you read my earlier Project of the Week: Build a Quota Ledger on Aleo with leo devnode, keep it around for local execution workflow ideas. After this one, Project of the Week: Build a Browser-First Private Transfer App with create-leo-app is a nice follow-up if you want a frontend.

What we're building

Our project is an Allowance Ledger. An admin can set or top up a user's allowance in a public mapping, and the user can spend from that allowance later. Nothing fancy. That is the point.

Two kinds of tests drive the tutorial. First, we write fast compiled tests for pure transition logic like add and subtract previews. Then we write a script test that awaits async transitions and checks mapping state directly.

I like this shape for Leo because it keeps the privacy story honest. The mapping is public, so the remaining allowance is public too. If that bothers you, good. That means you are thinking about Aleo's privacy boundary instead of waving your hands and calling everything zero knowledge.

Prerequisites

You need a recent Leo CLI with native testing support.

bash
leo --version

A basic command-line setup is enough for this project. You do not need a live network just to validate the logic here, and that is a relief because most allowance bugs are boring local bugs, not consensus bugs.

Create the project and move into it.

bash
leo new allowance_ledger
cd allowance_ledger
mkdir -p tests

Step-by-step

Step 1: Set the manifest

Open program.json and replace it with the full manifest below.

json
{
  "program": "allowance_ledger.aleo",
  "version": "0.1.0",
  "description": "A test-first allowance ledger for Leo",
  "license": "MIT"
}

Nothing exotic lives here. The manifest tells Leo the program id, version, and a short description. Keep it boring. Boring manifests are good manifests.

Step 2: Write the ledger

Open src/main.leo and paste the full program.

leo
program allowance_ledger.aleo {
    mapping allowances: address => u64;

    record AdminCap {
        owner: address,
    }

    transition mint_admin(public receiver: address) -> AdminCap {
        return AdminCap {
            owner: receiver,
        };
    }

    transition add_preview(current: u64, delta: u64) -> u64 {
        return current + delta;
    }

    transition spend_preview(current: u64, amount: u64) -> u64 {
        assert(current >= amount);
        return current - amount;
    }

    async transition set_allowance(
        cap: AdminCap,
        public user: address,
        public amount: u64,
    ) -> Future {
        assert_eq(cap.owner, self.signer);
        return finalize_set_allowance(user, amount);
    }

    async function finalize_set_allowance(user: address, amount: u64) {
        allowances.set(user, amount);
    }

    async transition top_up_allowance(
        cap: AdminCap,
        public user: address,
        public delta: u64,
    ) -> Future {
        assert_eq(cap.owner, self.signer);
        return finalize_top_up_allowance(user, delta);
    }

    async function finalize_top_up_allowance(user: address, delta: u64) {
        let current: u64 = allowances.get_or_use(user, 0u64);
        allowances.set(user, current + delta);
    }

    async transition spend_allowance(public amount: u64) -> Future {
        let user: address = self.signer;
        return finalize_spend_allowance(user, amount);
    }

    async function finalize_spend_allowance(user: address, amount: u64) {
        let current: u64 = allowances.get_or_use(user, 0u64);
        assert(current >= amount);
        allowances.set(user, current - amount);
    }
}

A few design choices are doing real work here.

mapping allowances: address => u64; holds the public allowance state. That is deliberate. If a team tells you a mapping is private because the chain is privacy-first, they are selling vibes.

record AdminCap is a tiny capability record. Owning that record is what authorizes an admin write. I prefer this over hard-coding a privileged address in a beginner tutorial because it keeps the example focused on Leo's record model.

mint_admin is intentionally simple. In a production app, you would lock that down or replace it with a safer bootstrap path. For a local test harness, it keeps the example readable and lets us focus on the testing framework instead of admin ceremony.

add_preview and spend_preview are pure logic transitions. They do no mapping I/O. That makes them cheap to test, and it lets you pin down arithmetic behavior before you deal with async state.

set_allowance and top_up_allowance are the mapping writers. The transition body checks authority with self.signer, then passes the public state update into a paired async function. That pairing is the whole point of Leo's async model: private proof work first, public state mutation after.

spend_allowance uses the signer as the user whose allowance gets reduced. I like that better than passing a user address as a parameter because it matches the mental model people already have. You spend your own budget. You do not submit a random address and hope the contract is feeling generous.

Step 3: Add native tests

Create tests/test_allowance_ledger.leo and paste this file.

leo
import allowance_ledger.aleo;

@test
transition test_add_preview() {
    let result: u64 = allowance_ledger.aleo/add_preview(10u64, 4u64);
    assert_eq(result, 14u64);
}

@test
transition test_spend_preview() {
    let result: u64 = allowance_ledger.aleo/spend_preview(10u64, 3u64);
    assert_eq(result, 7u64);
}

@test
@should_fail
transition test_spend_preview_rejects_overspend() {
    let result: u64 = allowance_ledger.aleo/spend_preview(2u64, 3u64);
    assert_eq(result, 0u64);
}

@test
script test_async_allowance_flow() {
    let set_fut: Future = allowance_ledger.aleo/set_allowance(
        allowance_ledger.aleo/mint_admin(self.signer),
        self.signer,
        9u64,
    );
    set_fut.await();
    assert_eq(Mapping::get(allowance_ledger.aleo/allowances, self.signer), 9u64);

    let top_up_fut: Future = allowance_ledger.aleo/top_up_allowance(
        allowance_ledger.aleo/mint_admin(self.signer),
        self.signer,
        6u64,
    );
    top_up_fut.await();
    assert_eq(Mapping::get(allowance_ledger.aleo/allowances, self.signer), 15u64);

    let spend_fut: Future = allowance_ledger.aleo/spend_allowance(4u64);
    spend_fut.await();
    assert_eq(Mapping::get(allowance_ledger.aleo/allowances, self.signer), 11u64);
}

Here is the split that matters.

The first three tests are compiled tests. They call regular transitions and assert on returned values. Fast. Direct. No state setup drama.

The last test is a script. Scripts are where Leo's test framework gets practical for async state. We call the async transition, await the Future, then inspect the mapping with Mapping::get.

A small detail I like here: the script mints a fresh AdminCap inline for each admin operation. That avoids a mess of extra ceremony in the test file and keeps the reader's eyes on the state transition we actually care about.

Negative tests matter too. @should_fail on test_spend_preview_rejects_overspend locks in behavior that should never quietly change later. If someone edits spend_preview and removes the assertion, the suite will complain immediately. Good. Let it complain.

Step 4: Build and do a quick local run

Run a compile first.

bash
leo build

Then exercise the pure transitions with leo run.

bash
leo run add_preview 10u64 4u64
leo run spend_preview 10u64 3u64

leo run is handy for the pure paths because it gives you quick feedback while you are still shaping logic. I would not use it as your main safety net for mapping-heavy code. Once async state enters the picture, the native test runner is the better place to live.

Testing

Run the full suite.

bash
leo test

If you only want the pure-function tests, filter by name.

bash
leo test preview

If you want the mapping-backed flow only, run the script test by name.

bash
leo test async_allowance_flow

A passing run tells you a few things. Arithmetic preview logic is stable. Overspending is rejected. The async path can set, top up, and spend from the mapping in the order you expect.

That combination is why I like test-first Leo examples right now. Pure tests catch small math mistakes early. Script tests catch the state bugs that would otherwise waste your afternoon.

You can push the suite a bit harder if you want. Try changing top_up_allowance to write delta directly instead of current + delta, then rerun leo test. One red test will teach more than five paragraphs of polite documentation.

What's next

A better version of this project would lock down mint_admin so random callers cannot mint their own capability records. Production code needs a real bootstrap or governance path. Tutorials get one free shortcut. Only one.

Privacy is the next fork in the road. If a public mapping is wrong for your use case, move the remaining allowance into records instead of pretending the mapping is hidden. Aleo gives you both tools, and choosing the wrong one on day one is how teams back themselves into a corner.

For local network execution, pair this tutorial with my earlier Quota Ledger on Aleo with leo devnode. For UI work, take the ledger ideas into my Browser-First Private Transfer App with create-leo-app.

A lion's final opinion, then I will stop prowling around your terminal: Leo testing is finally useful enough that there is no excuse for shipping a stateful program with zero local coverage. Write the pure tests first. Add the script when mappings show up. Sleep better.

Sources