Verifying a zero-knowledge proof onchain is mostly a matter of doing cryptography under a transaction budget. The proof may be small and the verifier may be deterministic, but under the hood the contract still has to run a long sequence of field operations, elliptic-curve operations, transcript and pairing checks. If those operations run entirely inside contract WASM, verification becomes expensive very quickly.
The X-Ray and Yardstick protocol upgrades addressed exactly this problem for Stellar. Protocol 25 introduced native support for the Poseidon hash function and the BN254 elliptic curve, two primitives that matter a lot for modern zk systems. BN254 is especially important because it is widely used across Ethereum and many zkSNARK ecosystems, including UltraHonk the verifier used by Noir. Protocol 26 went one step further. It added more native operations over BN254, including field arithmetic and multi-scalar multiplication, making it possible to move much more of the verifier’s heavy work out of guest WASM and into Stellar’s native host environment.
The first Noir UltraHonk verifier could already be deployed and executed onchain. The real problem was giving a good user experience. A zk application rarely verifies a proof in isolation. For example, a shielded pool still needs to check nullifiers, update commitments, and execute withdrawals. An identity application may need to update state, issue credentials, or call other contracts. If proof verification alone consumes most of the transaction budget, the application has very little space left to do useful work.
The baseline verifier consumed 224.8M CPU instructions for a single proof verification, around 56% of the current 400M instruction limit. That number only covered the verifier itself, before any additional application logic.
Our work focused on reducing that cost. By migrating the verifier to the Protocol 26 host functions and optimizing the implementation around them, we moved the most expensive cryptographic operations out of guest WASM and into native, metered host calls.
The result is a verifier that is smaller, cheaper, and much easier to compose with real applications.
The final verifier uses roughly 20% of the current 400M CPU instruction limit on every circuit we tested. That leaves about 80% of the transaction budget for the application logic that calls it. On live Testnet, a shielded-pool verification burned 82.1M instructions and cost 0.01242 XLM in real fees.
Efficient onchain verification of Noir proofs is what makes Noir-based applications possible on Stellar. But verification is not cheap. The verifier performs thousands of BN254 field operations and elliptic-curve operations, and every operation has to be paid for inside the transaction budget. On protocol 26 and 27, the total budge for each transaction is capped of 400M CPU instructions.
A verifier that consumes hundreds of millions of instructions may technically fit inside that limit, but that is not enough. The application still needs room for its own logic: deposits, withdrawals, identity checks, nullifier updates, state transitions, emitted events, or calls to other contracts.
That was the practical issue with the baseline verifier. It worked, but it left too little headroom.
We wanted more than an executable verifier. It had to be cheap enough to compose with real application logic and predictable enough to run on a live network.
We started from the pre-optimization baseline commit f13bd6b7. On a localnet with default limits, the first verify_proof submission failed immediately:
transaction simulation failed: HostError: Error(Budget, ExceededLimit)
With unlimited limits, the verifier completed successfully, but it consumed 224,763,006 CPU instructions—56.2% of the current 400M public-network ceiling. The quoted localnet minimum resource fee was 2,347,718 stroops (0.23477 XLM). The WASM binary was 66,109 bytes, mostly because arkworks field arithmetic was being compiled into the guest.
So the verifier was not broken. It was just too expensive for the kind of applications we wanted to support.
The first change was the largest one.
In the baseline, scalar-field arithmetic happened inside guest WASM through arkworks. The field.rs file defined Fr as a wrapper around ark_bn254::Fr, and operations like Add, Sub, Mul, and Neg were ordinary Rust operations compiled into the contract.
That meant every field addition and multiplication was paid for as WASM execution.
// Before (P0)
use ark_bn254::Fr as ArkFr;
impl Add for Fr {
fn add(self, rhs: Fr) -> Fr {
Fr(self.0 + rhs.0)
}
}
After the migration, Fr wraps soroban_sdk::crypto::bn254::Bn254Fr, and field operations call into the Soroban host:
// After (P1)
pub use soroban_sdk::crypto::bn254::Bn254Fr as ArkFr;
impl Add<&Fr> for Fr {
fn add(self, rhs: &Fr) -> Fr {
Fr(self.0.env().crypto().bn254().fr_add(&self.0, &rhs.0))
}
}
The change matters beyond syntax. The verifier no longer carries a full guest-side implementation of BN254 scalar arithmetic. Instead, it delegates the operation to the native BN254 functions exposed by the host.
These host functions, including bn254_fr_add, bn254_fr_mul, and the rest of the scalar-arithmetic family, were introduced in CAP-0080 as part of Protocol 26. The earlier G1 add, G1 multiplication, and pairing primitives arrived in CAP-0074 with Protocol 25.
The effect was immediate:
Dropping the guest-side arkworks field arithmetic shrank the binary by about 60% and cut almost 89M instructions. This was the single biggest absolute reduction in the whole project.
After field arithmetic moved to the host, the next gains came from reducing guest-side overhead around the verifier logic.
This was not one big change. It was a group of smaller cuts in places that run often: field helpers, relation evaluation, indexing, cloning, and conversions.
One example was in relations.rs. The baseline used a helper that cloned field elements every time a wire value was accessed:
// Before
fn wire(vals: &[Fr], w: Wire) -> Fr {
vals[w.index()].clone()
}
let q_arith = wire(p, Wire::QArith);
We replaced this with indexing and references:
// After
impl Index<Wire> for [Fr] {
...
}
let q_arith = &p[Wire::QArith];
In field.rs, we also replaced repeated Fr::one and Fr::zero constructors and duplicated operator implementations with helpers and a single binop_fr! macro.
The commit graph is not perfectly sequential: the G1 point-type adoption from the next step landed in the middle of this block. So our P2 measurement captures the guest-WASM cleanups plus G1 type adoption, before native MSM.
Even so, the result is useful:
These were not sophisticated optimizations, but they removed another 9M instructions from the verifier.
The final major change was replacing a hand-rolled G1 multi-scalar multiplication loop with a single host call.
In P2, the verifier iterated over commitments and scalars inside guest WASM. For every term, it called g1_mul, then accumulated the result with g1_add:
// Before
let bn = env.crypto().bn254();
let mut acc = Bn254G1Affine::from_array(env, &G1_INFINITY_AFFINE_BYTES);
for (c, s) in coms.iter().zip(scalars.iter()) {
if *s == zero {
continue;
}
let term = bn.g1_mul(c.as_bn254(), &s.0);
acc = bn.g1_add(&acc, &term);
}
After the change, the verifier builds two vectors, one for the points and one for the scalars, and calls g1_msm once:
// After
let mut vp: Vec<Bn254G1Affine> = Vec::new(env);
let mut vs: Vec<Bn254Fr> = Vec::new(env);
for (c, s) in coms.iter().zip(scalars.iter()) {
if *s == zero {
continue;
}
vp.push_back(c.0.clone());
vs.push_back(s.0.clone());
}
Ok(env.crypto().bn254().g1_msm(vp, vs))
We filter zero-scalar terms before the call because the Bn254G1Msm budget cost is charged per input term.
This was the change that pushed the verifier below 100M instructions:
The WASM grew slightly because the g1_msm integration added some orchestration code. But the final binary was still 62% smaller than the baseline, and the CPU cost dropped to roughly 80M instructions.

At a high level, P0 ran field and curve arithmetic inside guest WASM. P3 keeps the verifier orchestration in the guest and delegates the heavy cryptography to the host through env.crypto().bn254().
That boundary crossing is the main reason the instruction count fell from 224.8M to 80.0M.
We also wanted to make sure the result was not specific to a toy circuit.
We ran the optimised verifier against eight circuits on localnet. The CPU spread was only 2.68%:
The similar numbers are expected. UltraHonk proofs are constant-sized, and the verifier cost is mostly driven by the verifier structure rather than by the original Noir program.
Localnet fee quotes are useful for comparison, but they are not the same as public-network fees. To anchor the numbers to a real network, we deployed the tornado verifier to Stellar Testnet:
The instruction count matched localnet exactly. The actual fee charged on Testnet was 0.01242 XLM.
The final verification cost matters less than what it leaves behind: the remaining budget.
At around 80M instructions, the optimized verifier leaves roughly 318M to 320M instructions free inside the 400M transaction budget:
With the baseline verifier, any extra execution had to compete with proof verification already consuming more than half of the transaction budget.
With the optimized verifier, applications have enough remaining budget to do meaningful work after the proof is accepted.
There are still limits.
The first one is proof size. UltraHonk proofs are padded to a constant 14,592 bytes because Barretenberg’s CONST_PROOF_SIZE_LOG_N = 28 fixes the maximum circuit size at 2^28 gates. This gives predictable envelope sizing, but small circuits do not produce smaller proofs.
The second one is tooling. The verifier is practical today, but using it could still be smoother. Circuit-specific verifier generation, tighter Noir-to-Soroban type bindings, and better developer workflows would make this easier to integrate into production applications.
The main result is simple: Noir proof verification on Stellar is now much more practical.
The baseline verifier could run, but it consumed too much of the transaction budget to be comfortable for real applications. After moving field arithmetic and G1 multi-scalar multiplication to the Protocol 26 host functions, the verifier dropped from 224.8M to about 80M CPU instructions.
That is a 64.4% reduction in CPU instructions, a 61.7% reduction in localnet minimum resource fees, and a 62% reduction in WASM size.
More importantly, the optimized verifier leaves roughly 80% of the transaction budget available for the application that calls it.
The work was not about clever tricks as much as using the right boundary. Guest WASM should orchestrate the verifier. Native host functions should do the heavy cryptography. Once the verifier followed that split, the cost profile changed completely.