Introduction
Compiler fixes rarely look like application architecture. Two recent Leo fixes are a good exception.
ProvableHQ/leo PR #29642 keeps storage operations inside the else if branch that owns them. Its regression coverage includes vector operations and successive storage guards. PR #29646 changes mandatory inlining to track full, resolved function identities, then tests selective inlining when different libraries export functions with the same name.
Those bugs live in different compiler passes, but they suggest one useful test pattern for stateful Leo programs: compile hostile control flow, inspect the Aleo instructions, then execute state assertions. A source-level test alone can miss bad lowering. A bytecode snapshot alone cannot prove that the VM changed only the intended state. I want both.
We will build a small payment router with low, standard, and high tiers. Each tier updates a different storage counter and appends one tag to a storage vector. Two later guards update broader counters. The quote calculation calls discount from two separate Leo libraries, forcing the compiler to keep their identities straight while inlining them.
If you built the earlier test-first allowance ledger, the testing loop will feel familiar. The difference is where we aim the flashlight. That project tested application behavior. Our lab also treats generated Aleo instructions as a compiler artifact worth reviewing.
The library collision also connects to the stub-ordering composability boundary. Fully qualified names are not decorative once imports, generated stubs, and mandatory inlining meet.
What we're building
The finished tree looks like this:
compiler_regression_lab/
├── retail_rules/
│ └── src/lib.leo
├── vip_rules/
│ └── src/lib.leo
└── branch_lab/
├── program.json
├── src/main.leo
└── tests/test_branch_lab.leo
retail_rules::discount returns 5 percent using integer division. vip_rules::discount returns 20 percent. Their names and signatures match, while their bodies do not.
branch_lab.aleo exposes two entry points:
previewcalculates a tier and amount due without touching state.chargecreates a private receipt and returns aFinalblock that updates public storage.
The state model has per-tier counters, two counters controlled by successive guards, and a reached vector containing one tier tag per charge. Public storage is appropriate here because these values form an audit trail. The receipt itself is private and includes the required owner: address field.
Prerequisites
Use Leo 4.4.5. That release is paired with snarkVM 4.11.0 and contains both compiler fixes discussed here.
cargo install leo-lang --version 4.4.5
leo --version
Create the packages:
mkdir compiler_regression_lab
cd compiler_regression_lab
leo new retail_rules --library
leo new vip_rules --library
leo new branch_lab
cd branch_lab
leo add --local ../retail_rules retail_rules
leo add --local ../vip_rules vip_rules
cd ..
Libraries have no on-chain program ID. Their helper functions are compiled into the consuming program, which is exactly why the inliner must distinguish retail_rules::discount from vip_rules::discount.
Step-by-step
1. Write both libraries
Replace retail_rules/src/lib.leo with:
export fn discount(amount: u64) -> u64 {
return amount / 20u64;
}
Replace vip_rules/src/lib.leo with:
export fn discount(amount: u64) -> u64 {
return amount / 5u64;
}
The export modifier matters. Library items are private unless exported. Matching names are deliberate; different divisors give us an observable result after inlining.
Validate each library before involving the program:
cd retail_rules
leo build
cd ../vip_rules
leo build
cd ..
A library build performs parsing and semantic checks but emits no Aleo bytecode. Libraries exist only at source level.
2. Add the manifest
Use this complete branch_lab/program.json:
{
"program": "branch_lab.aleo",
"version": "0.1.0",
"description": "Compiler regression lab for storage branches and library inlining",
"license": "MIT",
"dependencies": [
{
"name": "retail_rules",
"location": "local",
"path": "../retail_rules",
"edition": null
},
{
"name": "vip_rules",
"location": "local",
"path": "../vip_rules",
"edition": null
}
],
"dev_dependencies": null
}
There are no import statements for libraries. The dependency entries make retail_rules::discount and vip_rules::discount available through qualified paths.
3. Build the payment program
branch_lab/src/main.leo grows in three passes. The first two keep both discount rules inline, so each file compiles with no dependencies and gives you a baseline build. Start with the quote logic alone:
fn quote_amount(amount: u64) -> (u8, u64) {
if amount < 100u64 {
return (0u8, amount / 20u64);
} else if amount < 500u64 {
return (1u8, amount / 20u64);
} else {
return (2u8, amount / 5u64);
}
}
program branch_lab.aleo {
fn preview(amount: u64) -> (u8, u64) {
let (tier, discount): (u8, u64) = quote_amount(amount);
return (tier, amount - discount);
}
@noupgrade
constructor() {}
}
quote_amount sits outside the program block because it is a helper. Run leo build here to confirm the tier logic compiles.
Next, replace the file with the stateful version. The helper is unchanged, and the program gains a receipt record, storage, and charge:
fn quote_amount(amount: u64) -> (u8, u64) {
if amount < 100u64 {
return (0u8, amount / 20u64);
} else if amount < 500u64 {
return (1u8, amount / 20u64);
} else {
return (2u8, amount / 5u64);
}
}
program branch_lab.aleo {
record Receipt {
owner: address,
gross: u64,
discount: u64,
due: u64,
tier: u8,
}
storage low_count: u64;
storage standard_count: u64;
storage high_count: u64;
storage eligible_count: u64;
storage large_count: u64;
storage reached: [u8];
fn preview(amount: u64) -> (u8, u64) {
let (tier, discount): (u8, u64) = quote_amount(amount);
return (tier, amount - discount);
}
fn charge(public receiver: address, public amount: u64) -> (Receipt, u8, u64, Final) {
let (tier, discount): (u8, u64) = quote_amount(amount);
let due: u64 = amount - discount;
let receipt: Receipt = Receipt {
owner: receiver,
gross: amount,
discount: discount,
due: due,
tier: tier,
};
return (receipt, tier, due, final {
if amount < 100u64 {
let current: u64 = low_count.unwrap_or(0u64);
low_count = current + 1u64;
reached.push(0u8);
} else if amount < 500u64 {
let current: u64 = standard_count.unwrap_or(0u64);
standard_count = current + 1u64;
reached.push(1u8);
} else {
let current: u64 = high_count.unwrap_or(0u64);
high_count = current + 1u64;
reached.push(2u8);
}
if amount >= 100u64 {
let current: u64 = eligible_count.unwrap_or(0u64);
eligible_count = current + 1u64;
}
if amount >= 500u64 {
let current: u64 = large_count.unwrap_or(0u64);
large_count = current + 1u64;
}
});
}
@noupgrade
constructor() {}
}
Build this version and keep a copy of the compiled .aleo file. Then swap the inline arithmetic for the library calls by replacing quote_amount at the top of the file with:
fn quote_amount(amount: u64) -> (u8, u64) {
if amount < 100u64 {
return (0u8, retail_rules::discount(amount));
} else if amount < 500u64 {
return (1u8, retail_rules::discount(amount));
} else {
return (2u8, vip_rules::discount(amount));
}
}
Library functions must be inlined, so both qualified discount calls eventually become arithmetic in the caller's bytecode. In my build, the inline and library versions compiled to identical Aleo instructions, so a diff against the baseline copy is a cheap check that each call resolved to the right body.
The first finalization chain is the branch-sensitive case. A 250-unit payment must increment only standard_count and append only 1u8. Before PR #29642, an else if storage operation could escape the branch during storage lowering.
The later guards are intentionally separate. A 1,000-unit payment reaches both amount >= 100u64 and amount >= 500u64. A compiler must preserve both writes rather than treating the second guard as part of the first branch chain.
Singleton storage starts unset, so every counter uses unwrap_or(0u64). The vector starts empty and needs no constructor setup.
4. Compile and inspect
Build the program:
cd branch_lab
leo build
Leo writes the compiled program under build/branch_lab.aleo/. Locate and inspect it with:
find build/branch_lab.aleo -type f -name '*.aleo' -print
ALEO_FILE="$(find build/branch_lab.aleo -type f -name '*.aleo' | head -n 1)"
cat "$ALEO_FILE"
grep -nE '20u64|5u64|branch|set|position' "$ALEO_FILE"
Do not turn this into a brittle whole-file snapshot unless you pin the compiler version. Register allocation and harmless formatting can change. I prefer focused assertions that check semantic landmarks:
- Both
20u64and5u64appear in the quote logic, proving that the two same-named library bodies survived resolution. - The finalization section contains distinct guarded writes for each tier.
- Vector position and length writes remain under the selected tier path.
- Both later guard regions remain present.
The exact register numbers are compiler details. The branch boundaries are not.
Run the pure entry point as a quick check:
leo run preview 50u64
leo run preview 250u64
leo run preview 1000u64
Expected outputs are (0u8, 48u64), (1u8, 238u64), and (2u8, 800u64). leo run does not execute the final {} block, so use it for quote arithmetic, not storage claims.
Testing
Replace the contents of branch_lab/tests/test_branch_lab.leo. The file starts with import branch_lab.aleo;, followed by a program test_branch_lab.aleo { } block. Inside that block, add a @noupgrade constructor() {} and these two tests:
@test
fn test_quotes_use_library_bodies() {
let low: (u8, u64) = branch_lab.aleo::preview(50u64);
let standard: (u8, u64) = branch_lab.aleo::preview(250u64);
let high: (u8, u64) = branch_lab.aleo::preview(1000u64);
assert_eq(low, (0u8, 48u64));
assert_eq(standard, (1u8, 238u64));
assert_eq(high, (2u8, 800u64));
}
@test
fn test_branch_writes_and_guards() -> Final {
let receiver: address = std::ctx::signer();
let (low_receipt, low_tier, low_due, low): (branch_lab.aleo::Receipt, u8, u64, Final) =
branch_lab.aleo::charge(receiver, 50u64);
let (standard_receipt, standard_tier, standard_due, standard): (branch_lab.aleo::Receipt, u8, u64, Final) =
branch_lab.aleo::charge(receiver, 250u64);
let (high_receipt, high_tier, high_due, high): (branch_lab.aleo::Receipt, u8, u64, Final) =
branch_lab.aleo::charge(receiver, 1000u64);
return final {
low.run();
assert_eq(branch_lab.aleo::low_count.unwrap_or(0u64), 1u64);
assert_eq(branch_lab.aleo::standard_count.unwrap_or(0u64), 0u64);
assert_eq(branch_lab.aleo::high_count.unwrap_or(0u64), 0u64);
assert_eq(branch_lab.aleo::eligible_count.unwrap_or(0u64), 0u64);
assert_eq(branch_lab.aleo::large_count.unwrap_or(0u64), 0u64);
assert_eq(branch_lab.aleo::reached.len(), 1u32);
assert_eq(branch_lab.aleo::reached.get(0u32).unwrap(), 0u8);
standard.run();
assert_eq(branch_lab.aleo::low_count.unwrap_or(0u64), 1u64);
assert_eq(branch_lab.aleo::standard_count.unwrap_or(0u64), 1u64);
assert_eq(branch_lab.aleo::high_count.unwrap_or(0u64), 0u64);
assert_eq(branch_lab.aleo::eligible_count.unwrap_or(0u64), 1u64);
assert_eq(branch_lab.aleo::large_count.unwrap_or(0u64), 0u64);
assert_eq(branch_lab.aleo::reached.len(), 2u32);
assert_eq(branch_lab.aleo::reached.get(1u32).unwrap(), 1u8);
high.run();
assert_eq(branch_lab.aleo::low_count.unwrap_or(0u64), 1u64);
assert_eq(branch_lab.aleo::standard_count.unwrap_or(0u64), 1u64);
assert_eq(branch_lab.aleo::high_count.unwrap_or(0u64), 1u64);
assert_eq(branch_lab.aleo::eligible_count.unwrap_or(0u64), 2u64);
assert_eq(branch_lab.aleo::large_count.unwrap_or(0u64), 1u64);
assert_eq(branch_lab.aleo::reached.len(), 3u32);
assert_eq(branch_lab.aleo::reached.get(2u32).unwrap(), 2u8);
};
}
Run the complete suite and each regression separately:
leo test
leo test test_quotes_use_library_bodies
leo test test_branch_writes_and_guards
Leo tests execute through the real VM, including finalization. Storage reads are only legal in finalization code, so the second test returns Final and does its checking inside its own final block. Each charge call hands back a Final handle, and calling .run() on that handle executes the charge's storage writes. The test reads public storage after each .run(). The assertions are deliberately repetitive. A compact final snapshot would miss the moment when an unreachable branch wrote state and a later call happened to hide the damage.
The test catches four failure classes:
- A low payment writing the standard or high counter.
- An
else ifvector push occurring outside its branch. - A high payment failing to execute both successive guards.
- The VIP call being inlined with the retail function body because both functions are named
discount.
That is a decent amount of compiler pressure from a very small program. The lion approves.
What's next
Put the build inspection and leo test commands in CI, with Leo pinned to 4.4.5. Save a small normalized excerpt of the finalization bytecode rather than every generated register. Review changes whenever the compiler version moves.
For a larger stateful program, copy the same pattern around authorization branches, capped withdrawals, or storage-vector maintenance. Pick inputs that reach each branch boundary, then add cases that trigger successive independent guards.
I would also add a negative fixture compiled with an older affected compiler in a container. Do not use it for deployment. Its job is to prove that the lab can detect the historical regression instead of producing a permanently green test that never challenged anything.
Keep the three layers separate: source intent, generated instructions, and VM state. A compiler regression can violate one boundary while leaving another apparently healthy. Testing all three is slower than checking a return value, but stateful code deserves the extra bite.