Introduction
The last draft jumped the gun.
Leo 3.5 does not expose verifying_key, proof, or snark::verify in ordinary program source, so the earlier version of this post could not compile. That means direct proof verification is not something you can write in plain Leo here, at least not with the symbols the compiler knows today.
Missed last week's release roundup? I covered the bigger picture in Aleo This Week: Leo 3.5, snarkOS 4.5, and the Rise of Private Stablecoins. Here, we are getting our paws dirty with the closest thing that does compile and still pulls its weight: a Leo program that pins public config, recomputes the claim hash, blocks duplicate claims, and gives the caller a private receipt record.
One opinion up front. Do not dump attestation payloads into public state just because you can. Store the minimum on-chain. Hand the user a private record. Anything else leaks more than the app needs.
Three corrections before we touch code. First, source-level proof verification is out. Your app, backend, or prover service has to verify the outside proof before it calls this Leo program. Second, Leo 3.5 does not have constructors. The compiler was right to bark. The fix is a one-time initialize transition that writes public config after deployment. Third, the previous draft called inline helper functions from inside async function finalize blocks, which the compiler rejects. Inline helpers are transition-only in Leo 3.5, so the safe move is to compute the hash directly inside finalize with a struct and BHP256::hash_to_field.
What we're building
Our project is still called proof_gated_attestation.aleo, but the honest description is narrower: it is an attestation claim registry for claims that have already been checked off-chain.
An external verifier checks whatever statement your app cares about. Maybe the user passed KYC with a partner, maybe they proved membership in a set, maybe they satisfied some off-chain compliance rule. After that check passes, the Leo program pins the issuer tag, recomputes the public claim hash, rejects duplicates, and records a one-time claim_id on-chain.
A few design choices matter here.
- The issuer ID and verifier-key hash tag live in public config mappings set once through
initialize. - The claim registry is public because replay protection has to be globally visible somewhere.
- The receipt is a record with
owner: address, so the caller gets a private artifact they can hold or pass into later flows. - The attestation body stays small. That keeps the public side boring, which is exactly what you want.
One subtle point. The stored verifier_key_hash is only a config tag. Leo is not consuming a verifying_key object in this version because that type is not available to the compiler here. Your outside verifier should check the real proof and key, then call claim only after that passes.
Prerequisites
Aleo work gets annoying fast when your versions drift, so pin them before you write code.
You need a recent Leo toolchain that supports transition, async transition, paired async function finalize blocks, mappings, and record outputs. You also need an outside verifier workflow that checks the proof before the Aleo call and produces the public values your Leo program expects, especially claim_id, issuer_id, and public_inputs_hash.
Have these ready before you start:
- Leo installed and on your path.
- A local project folder.
- A verifier service or local script that validates the outside proof before you submit the Aleo transaction.
- A fixed
issuer_idandverifier_key_hashtag you want to initialize on-chain with. - Sample claim values for
subject,schema,salt, and the derivedclaim_id.
One more honest note. Aleo's architecture allows proving to happen locally or through a third-party prover. Signing is separate from proving, which is good. Privacy still depends on what witness data you hand to that prover. If the witness is sensitive, treat outsourced proving as a trust decision, not a magic trick.
Step-by-step
1. Create the project
Start with a clean Leo app.
leo new proof_gated_attestation
cd proof_gated_attestation
Replace the generated manifest with this program.json:
{
"program": "proof_gated_attestation.aleo",
"version": "0.1.0",
"description": "Minimal attestation claim registry with config pinning and replay protection",
"license": "MIT"
}
Nothing fancy here. Keeping the manifest tiny is fine for a tutorial like this.
2. Write the Leo program
Drop the following into src/main.leo.
program proof_gated_attestation.aleo {
mapping config_fields: u8 => field;
mapping config_ready: u8 => bool;
mapping total_claims: u8 => u64;
mapping claimed: field => bool;
record ClaimReceipt {
owner: address,
claim_id: field,
subject: field,
schema: field,
issuer_id: field,
}
struct ClaimIdInput {
subject: field,
schema: field,
salt: field,
}
struct ClaimData {
claim_id: field,
subject: field,
schema: field,
issuer_id: field,
}
transition derive_claim_id(
public subject: field,
public schema: field,
public salt: field
) -> field {
let input: ClaimIdInput = ClaimIdInput {
subject: subject,
schema: schema,
salt: salt,
};
return BHP256::hash_to_field(input);
}
transition derive_public_inputs_hash(
public claim_id: field,
public subject: field,
public schema: field,
public issuer_id: field
) -> field {
let data: ClaimData = ClaimData {
claim_id: claim_id,
subject: subject,
schema: schema,
issuer_id: issuer_id,
};
return BHP256::hash_to_field(data);
}
async transition initialize(
public issuer_id: field,
public verifier_key_hash: field
) -> Future {
return finalize_initialize(issuer_id, verifier_key_hash);
}
async function finalize_initialize(
issuer_id: field,
verifier_key_hash: field
) {
let ready: bool = config_ready.get_or_use(0u8, false);
assert_eq(ready, false);
config_fields.set(0u8, issuer_id);
config_fields.set(1u8, verifier_key_hash);
total_claims.set(0u8, 0u64);
config_ready.set(0u8, true);
}
async transition claim(
public claim_id: field,
public subject: field,
public schema: field,
public issuer_id: field,
public public_inputs_hash: field,
public verifier_key_hash: field
) -> (ClaimReceipt, Future) {
let receipt: ClaimReceipt = ClaimReceipt {
owner: self.signer,
claim_id: claim_id,
subject: subject,
schema: schema,
issuer_id: issuer_id,
};
return (
receipt,
finalize_claim(
claim_id,
subject,
schema,
issuer_id,
public_inputs_hash,
verifier_key_hash
)
);
}
async function finalize_claim(
claim_id: field,
subject: field,
schema: field,
issuer_id: field,
public_inputs_hash: field,
verifier_key_hash: field
) {
let ready: bool = config_ready.get_or_use(0u8, false);
assert_eq(ready, true);
let stored_issuer_id: field = config_fields.get_or_use(0u8, 0field);
let stored_vk_hash: field = config_fields.get_or_use(1u8, 0field);
assert_eq(issuer_id, stored_issuer_id);
assert_eq(verifier_key_hash, stored_vk_hash);
let data: ClaimData = ClaimData {
claim_id: claim_id,
subject: subject,
schema: schema,
issuer_id: issuer_id,
};
let expected_hash: field = BHP256::hash_to_field(data);
assert_eq(public_inputs_hash, expected_hash);
let already_claimed: bool = claimed.get_or_use(claim_id, false);
assert_eq(already_claimed, false);
claimed.set(claim_id, true);
let current_total: u64 = total_claims.get_or_use(0u8, 0u64);
total_claims.set(0u8, current_total + 1u64);
}
}
A few things are doing real work here.
initialize replaces the old constructor idea. It writes config once, and config_ready makes sure nobody can quietly reconfigure the program later.
claimed is the replay guard. Once a claim_id lands, the same claim cannot be recorded again. Public state is the right place for that check because duplicate prevention has to be globally visible.
ClaimReceipt is private because it is a record. The chain keeps the minimal public fact, that a claim ID was accepted. The user keeps a receipt they can carry into another Leo flow later.
ClaimIdInput and ClaimData are the two hashing structs. The previous version used inline helper functions with array-literal hashing, and that is where the compiler gave up. BHP256::hash_to_field with a struct keeps the field order explicit and works in both transitions and finalize blocks.
derive_public_inputs_hash and the matching computation inside finalize_claim both construct a ClaimData struct and call BHP256::hash_to_field on it. Same struct, same field order, same hash. If your outside verifier is hashing something else, the mismatch will show up as a failed assert_eq in finalize rather than a silent wrong answer.
claim has two jobs. Off-chain, it builds the receipt record. On-chain, inside finalize_claim, it checks that config exists, compares the configured values, recomputes the public-input hash, rejects duplicates, then increments a counter. The actual outside proof check has to happen before this Leo call.
3. Understand the trust model
Here is the mental model I want you to keep.
The prover is allowed to do expensive work. The prover is not allowed to decide truth by itself. In this corrected version, your verifier service decides whether the proof is valid, and the Leo program decides whether the public claim data matches the pinned config and has not been used before.
That is a weaker trust boundary than true on-chain proof verification. I do not love that, but pretending the compiler supports symbols it does not support would be worse. If you build this today, keep the verifier in code you control, audit the path that turns a passed proof into a claim call, and keep the on-chain side narrow.
Because Leo 3.5 does not give us constructors, config becomes an explicit first step. That is not a bad trade. It makes setup visible, auditable, and easy to test.
In this version, the outside verifier and the Leo program should both agree on public_inputs_hash. The contract recomputes that hash from claim_id, subject, schema, and issuer_id using BHP256::hash_to_field(ClaimData { ... }), then refuses the claim if they do not match.
4. Prepare example inputs
For a concrete walkthrough, let us pretend your app tracks these public claim values:
claim_id = 9001fieldsubject = 4242fieldschema = 7fieldissuer_id = 55field
Pick a salt to derive the claim ID locally while testing:
leo run derive_claim_id 4242field 7field 99field
Then derive the public-input hash the contract expects:
leo run derive_public_inputs_hash 9001field 4242field 7field 55field
Before you can claim anything on-chain, initialize the program once:
leo execute initialize 55field 8888field --broadcast
At this point your outside verifier should check the real proof and verifying key in its own environment. Only after that passes should it submit the Aleo call with the matching public_inputs_hash and the pinned verifier_key_hash tag.
5. Build the project
Compile first. Always.
leo build
A clean build tells you the Leo side is structurally sound. It does not tell you your outside verifier is hashing the same values or accepting the right proof. Different problem.
6. Run the claim flow
Once your outside verifier accepts the proof, submit the claim transaction:
leo execute claim 9001field 4242field 7field 55field 7777field 8888field --broadcast
Two values deserve an extra sentence.
7777field is the public-input hash in this example, and 8888field is the verifier-key hash tag stored during initialize. Replace them with the real values from your verifier pipeline. Hard-code nothing except the deployed configuration you truly mean to trust.
Testing
Testing this pattern is half compiler work and half bad-input abuse.
Start with the happy path. Initialize once, have your outside verifier approve one proof, then submit one matching claim whose public_inputs_hash lines up with the Leo call exactly. You should get a ClaimReceipt back and one claim_id recorded on-chain.
Then break it on purpose.
- Re-submit the same
claim_id. The duplicate check should fail. - Skip
initializeand try to claim anyway. The config-ready check should fail. - Change
issuer_idwhile keeping the rest fixed. The config check should fail. - Change
public_inputs_hashonly. The recomputed hash check should fail. - Feed an invalid proof to your outside verifier and make sure it refuses to create the Leo transaction at all.
That test order matters. Cheap checks should happen before you pay for network work whenever you can manage it. I like programs that reject nonsense early.
A simple local prep loop looks like this:
leo build
leo run derive_claim_id 4242field 7field 99field
leo run derive_public_inputs_hash 9001field 4242field 7field 55field
For stateful behavior, use script tests or a devnet flow so initialize and claim can touch public mappings for real.
One rough edge is worth calling out. Proof serialization is still the least pleasant part of the stack. If your verifier service is choking on keys or proofs, do not start rewriting Leo immediately. Check version pinning first. A lot of pain starts there.
What's next
Plenty of room exists to grow this small pattern without bloating it.
A natural next step is schema-specific verifier routing. Instead of one configured verifier-key hash tag, store a mapping from schema to accepted tags. That gives you multiple attestation types while keeping each path explicit.
Another useful extension is consuming the ClaimReceipt record inside a second program. That gives you a clean privacy-first pipeline: verify off-chain once, mint a private capability, spend that capability later.
And yes, the obvious future upgrade is real source-level proof verification inside Leo once the compiler exposes the right API and types. When that day arrives, the claim boundary here is the spot to wire it in. Until then, keep the public side tiny, make every verifier gate explicit, and treat third-party provers as helpers, not oracles.