Introduction
I like on-chain verification when the chain actually needs shared state. If my app only needs a clean yes or no, I would rather prove in Leo, verify in the app layer, and move on.
This follows the same thread as my browser-first transfer post and my note on proof-gated attestation, but this time I am using the cleaner split: prove in Leo, verify in the app layer, then issue a receipt your product can use right away.
Setup
We are going to build a small Leo program called offchain_gateway.aleo. A user will prove that a private amount is greater than zero and no more than 1000u64, and that the hidden pair (amount, nonce) matches a public claim_hash.
After that, a TypeScript gateway will read the execution artifact, verify the proof with the Provable SDK, and write receipt.json. That file is app state, not chain state, which is exactly the split I want here.
Create the project and install the verifier bits:
mkdir offchain-proof-gateway
cd offchain-proof-gateway
leo new offchain_gateway
cd offchain_gateway
npm init -y
npm install @provablehq/sdk
npm install -D typescript tsx @types/node
If you use the shell shortcut below, install jq too. Keep program.json plain: set the program name to offchain_gateway.aleo, keep the version at 0.1.0, use an MIT license, and leave dependencies empty.
Leo program
Replace src/main.leo with this:
program offchain_gateway.aleo {
@noupgrade
async constructor() {}
struct PurchaseClaim {
amount: u64,
nonce: field,
}
inline claim_hash(amount: u64, nonce: field) -> field {
let claim: PurchaseClaim = PurchaseClaim {
amount: amount,
nonce: nonce,
};
return BHP256::hash_to_field(claim);
}
transition quote_claim_hash(amount: u64, nonce: field) -> field {
assert(amount > 0u64);
return claim_hash(amount, nonce);
}
transition prove_under_limit(
public claim_hash: field,
amount: u64,
nonce: field,
) -> field {
assert(amount > 0u64);
assert(amount <= 1000u64);
let expected_hash: field = claim_hash(amount, nonce);
assert_eq(expected_hash, claim_hash);
return claim_hash;
}
}
Current Leo syntax wants the program body wrapped in braces, the constructor policy written as @noupgrade, and CLI-callable entry points exposed as transitions. I kept the shared hash logic in an inline helper so the quote path and the proof path still use the same construction.
I dropped the record output from the earlier draft. For this pattern the proof is the product, so returning the public hash keeps the circuit small and avoids extra moving parts that do not help the verifier.
Now build, dry-run the helper, and generate an execution artifact:
leo build
leo run quote_claim_hash 42u64 7field
If you want the hash in a shell variable:
CLAIM_HASH=$(leo run quote_claim_hash 42u64 7field --json-output | jq -r 'if .output then .output elif .outputs then (if (.outputs | type) == "array" then .outputs else .outputs end) else empty end')
echo "$CLAIM_HASH"
Then execute the proof path:
leo execute prove_under_limit $CLAIM_HASH 42u64 7field --json-output > execution.json
That execution.json file is the hand-off point. Depending on the exact CLI build you are on, the proof, verifying key, and public inputs may sit under slightly different keys, so the verifier should handle small shape changes instead of assuming one exact layout forever.
Verifier
Create verify.ts in the project root:
import { snarkVerify } from '@provablehq/sdk/mainnet.js';
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
type JsonRecord = Record<string, unknown>;
function pick(obj: JsonRecord, paths: string[][]): unknown {
for (const path of paths) {
let current: unknown = obj;
let ok = true;
for (const key of path) {
if (typeof current !== 'object' || current === null || !(key in current)) {
ok = false;
break;
}
current = (current as Record<string, unknown>)[key];
}
if (ok && current !== undefined && current !== null) {
return current;
}
}
throw new Error(`Missing expected field: ${paths.map((p) => p.join('.')).join(', ')}`);
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [value];
}
const raw = await readFile('./execution.json', 'utf8');
const execution = JSON.parse(raw) as JsonRecord;
const proof = pick(execution, [
['proof'],
['execution', 'proof'],
['response', 'proof'],
]);
const verifyingKey = pick(execution, [
['verifying_key'],
['verifyingKey'],
['execution', 'verifying_key'],
['execution', 'verifyingKey'],
]);
const publicInputs = pick(execution, [
['public_inputs'],
['publicInputs'],
['execution', 'public_inputs'],
['execution', 'publicInputs'],
]);
const verified = await snarkVerify(verifyingKey, publicInputs, proof);
if (!verified) {
throw new Error('Proof verification failed');
}
const claimHash = String(asArray(publicInputs));
const receiptPayload = {
claimHash,
programId: 'offchain_gateway.aleo',
functionName: 'prove_under_limit',
verifiedAt: new Date().toISOString(),
verifier: 'provable-sdk-snarkVerify',
status: 'accepted',
};
const receiptId = createHash('sha256')
.update(JSON.stringify(receiptPayload))
.digest('hex');
await writeFile(
'./receipt.json',
JSON.stringify({ receiptId, ...receiptPayload }, null, 2),
);
console.log(`Verified proof for ${claimHash}`);
console.log(`Wrote receipt.json with id ${receiptId}`);
Run it with:
npx tsx verify.ts
If the proof checks out, you will get receipt.json. The verifier should be strict and the receipt should be boring. If verification fails, stop there. A maybe-receipt is useless.
Testing and fit
Test the path that should pass:
CLAIM_HASH=$(leo run quote_claim_hash 42u64 7field --json-output | jq -r 'if .output then .output elif .outputs then (if (.outputs | type) == "array" then .outputs else .outputs end) else empty end')
leo execute prove_under_limit $CLAIM_HASH 42u64 7field --json-output > execution.json
npx tsx verify.ts
cat receipt.json
Test the path that should fail in the circuit:
CLAIM_HASH=$(leo run quote_claim_hash 1001u64 7field --json-output | jq -r 'if .output then .output elif .outputs then (if (.outputs | type) == "array" then .outputs else .outputs end) else empty end')
leo execute prove_under_limit $CLAIM_HASH 1001u64 7field --json-output > execution.json
And test a tampered public statement by generating a valid proof, editing execution.json, changing the first public input, and running the verifier again:
npx tsx verify.ts
Those three checks cover the cases that matter. One proves the normal path works, one proves the circuit rejects bad witnesses, and one proves the receipt is tied to the same public statement the prover committed to.
This pattern is a good fit for gated downloads, checkout approvals, eligibility checks, or proof-backed access passes. If you later need shared settlement or public composability, move verification on-chain for that flow and pay consensus only where it buys you something real.