For most of NEAR’s history, every smart-contract call on mainnet has run through NearVM, a single-pass WebAssembly engine we forked from Wasmer and have maintained ourselves ever since. It is fast, deterministic, and — crucially — hardened against the kind of adversarial input a public blockchain attracts. It is also a large pile of bespoke compiler code that only we use, only we test, and only we fix.

Authors: Svyatoslav Savenko, Jakob Meier, Darioush Jalali

The plan for the 2.12 release was to retire it. We would switch the runtime to Wasmtime, the de-facto standard WebAssembly runtime maintained by the Bytecode Alliance, and stop carrying a VM of our own.

What looked like a contained dependency upgrade became a multi-month adventure fitting the off-the-shelf compiler to the blockchain requirements, limitations and inner workings. Here we summarize our findings, the fixes we shipped, and the decisions made along the way.

Why swap the VM?

NearVM works, but maintaining a compiler is a tax we would rather not pay. Every Rust/LLVM toolchain bump risks emitting WebAssembly features our VM doesn’t understand; every new wasm proposal is ours to support; every security fix in the broader ecosystem is ours to backport by hand. On top of that, we stopped pulling from the upstream Wasmer repository quite some time ago (right before the recently discovered critical RCE bug was introduced there). Moving to Wasmtime trades a maintenance burden for a dependency, and gets us a runtime that a large community fuzzes, optimizes, and patches.

The execution numbers backed the move. Running a shadow comparison on live mainnet traffic — nodes executing every chunk through both VMs and diffing the results — outcomes matched in the overwhelming majority of cases, with only tiny gas differences (well under 0.002%). And Wasmtime was faster at execution: median per-call time dropped from ~2 ms to ~500 µs, with the p99 tail improving from ~100 ms to ~20 ms.

Compilation time

Wasmtime offers two backends — the optimizing Cranelift and the single-pass Winch. We started with Cranelift, the default option, and ultimately shipped Winch. Compile time was one of the decisive factors.

NearVM is a single-pass compiler — it walks the wasm once and emits machine code. Cranelift is an optimizing compiler. It does real codegen work, and for our workload that makes it roughly 20× slower to compile a contract than NearVM.

That would be a footnote if compilation happened somewhere lazy. It doesn’t. On NEAR, a contract is compiled synchronously, on the chunk-application critical path — when a deploy lands. Chunk application runs against a sub-second budget (a 600 ms block-time floor, 1.8 s ceiling), and compilation is now part of it. If a single deploy takes too long, the chunk misses its slot and is skipped; sustained stalls can degrade block production.

So a 20× compile-time regression isn’t a performance footnote. It’s a new chain-availability attack surface, plus a set of operational hazards (recompilation storms, memory blowups) that we now had to reason about across deploys, first-calls, protocol upgrades, resharding, and new validators spinning up.

It is worth noting that in normal mainnet operation, compilation is a small fraction of the work a node does — the dominant cost by far is executing contracts, call after call, and there Wasmtime is actually faster than NearVM. The entire compilation fight was therefore never about the average case; it was about the long tail and malicious actors — the cold paths where compilation is forced (a fresh deploy, a first call after cache eviction, the recompile wave at a protocol upgrade), and an adversary who deliberately maximizes how often and how expensively those paths are hit. The average chunk would be fine. We had to make sure the worst chunk was, too.

Gas metering

Gas metering is how the protocol accounts for the computational work a contract performs: the VM inserts checks that accumulate the cost of executing each chunk of code, so the caller can be charged and a runaway contract can be halted once it exhausts its budget.

A large part of why Wasmtime is so much more expensive comes down to one architectural difference in how the two VMs meter gas.

NearVM is ours, so it meters gas the cheapest possible way: it emits the gas (and stack-height) checks directly as machine code during codegen, in the same single pass that compiles the contract. The metering never exists as WebAssembly — it materializes straight into native instructions, adding essentially nothing to what the compiler has to chew through.

Wasmtime is a general-purpose runtime; we can’t reach into its codegen to do that. Instead we meter gas in a preprocessing step: before Wasmtime ever sees the contract, we rewrite the wasm to inject explicit gas-charging and stack-check instructions at every basic-block boundary (this is finite-wasm, our metering scheme). Those checks are now real wasm instructions — and Cranelift has to compile every one of them. A single basic-block boundary expands into a stack check, two gas-charge sequences, and another stack check.

The effect is stark. Take a pathological contract of 996 functions with 1000 parameters each: NearVM’s prepared wasm is essentially unchanged at ~14.7 KB, while the Wasmtime-prepared wasm balloons to ~124 KB — an 8.5× inflation purely from materialized gas and stack metering. Every injected instruction is extra work for Cranelift and extra bytes in the native artifact. It is also why several of the adversarial shapes we found later are so effective: they multiply the number of basic-block boundaries, and the metering multiplies right along with them.

This difference is the single biggest lever we have for the future, and it is exactly what the planned alternative gas metering targets: replace the check inlined at every boundary with a single function call, so the instrumented wasm shrinks, compilation speeds up, and artifacts get smaller — at a runtime cost we measured as acceptable.

On a side note, Wasmtime ships its own gas-metering mechanism — “fuel”. It also gives deterministic interruption but, despite a lot of recent improvements, it is not flexible enough to match all our needs: its operation-to-cost model doesn’t line up with ours (for example, it lacks the size-linear charging we need for bulk ops like memory.fill), and it has no equivalent of our operand stack-height metering (max_stack_height), bounding native stack usage rather than the wasm operand stack. A promising direction is a hybrid: lean on native metering where it fits and keep our own instrumentation only where it doesn’t. A follow-up project of its own.

“A bomb in the mainnet”

The first scare came from simply re-deploying every existing mainnet contract onto Wasmtime and measuring the size inflation. Most contracts expanded predictably — instrumented and compiled artifacts a few times (~10× on average) larger than the source. But the tail was alarming: a handful of contracts showed compiled-to-original size ratios in the hundreds, and one apparently hit ~1200×.

A 1000× blowup on a 3 MB contract is 3 GB of native code. Cranelift would crash — OOM or otherwise — long before finishing, and certainly not within a block.

It took some digging to defuse this one. The worst offender turned out to be an essentially empty module: the 1200× was the fixed overhead floor of compiling any valid wasm (~9 KB), a ratio that only shrinks as you add real content. The genuine, scalable expansion is a two-stage pipeline — gas-metering instrumentation (~3× on code bytes) followed by native codegen (~4× on the instrumented code) — for a combined ~10× on code. While the 1200× expansion observed on mainnet turned out to be non-scalable, we were able to craft contracts with a similar expansion ratio — formally valid input (at the time) that compiled to ~500 MB artifacts. To keep even that bounded, we introduced the first of what became a whole battery of limits: max_instrumented_code_size (16 MB at protocol 84), which caps how much code Cranelift ever sees and pins the realistic worst case at ~30–35×, only modestly above what already exists in the wild.

The mainnet number was a false alarm — but a productive one, and, as the crafted ~500 MB artifacts showed, the underlying danger was real. It forced an early, precise understanding of where the size and time blow up and kicked off the limits work that followed.

The size story never entirely went away, though, because it collided with how we cache compiled code. The on-disk compiled-contract cache was append-only — it never evicted — so oversized artifacts simply accumulated, pressuring both disk and memory. Worse, a protocol upgrade that changes the VM invalidates every cache entry at once, guaranteeing a recompilation avalanche on the first call to each contract after the boundary — exactly when the fatter Wasmtime artifacts hurt most. So we tackled the cache lifecycle alongside the size limits: a targeted cache-cleanup on protocol upgrade, background cache pre-warming in the epoch before an upgrade (compiling the working set under the next VM config ahead of time), and proper size-bounded eviction queued as a follow-up. It is less acute than compilation time — a fat artifact doesn’t directly cost you a block — but it fed the same limits work.

Hunting compiler bombs

If natural contracts were mostly fine, crafted ones were not. Over several weeks we built adversarial contracts — and benchmarked them against the real corpus of mainnet contracts — to find wasm shapes that make Cranelift pathological:

  • Basic-block storms: thousands of tiny blocks chained in one function. A crafted 128 KB contract compiled in ~7 seconds (versus ~9 ms on NearVM).
  • call_indirect density, NaN-canonicalization storms, and long-lived locals that interact badly with our gas instrumentation.
  • Functions with ~1000 parameters, which explode Wasmtime’s call trampolines and roughly double artifact size per parameter — a corner case with no easy fix.

In response we introduced a battery of deterministic limits — caps on basic blocks per function and per contract, maximum function-body size, operand-stack depth, and types per contract — chosen against the observed maxima of real contracts so that no legitimate mainnet (and nearly all testnet) contracts would be affected.

A blanket compilation timeout wasn’t really an option: consensus needs every validator to reach the same verdict on a chunk, but wall-clock compile time is non-deterministic, so a borderline contract that lands just under the limit on one validator’s hardware and just over on another’s would fork the network (the planned follow-ups bring this option back to the table).

While the worst real single deploy compiled in around 0.6 seconds on high-end hardware, we kept discovering the “edge” cases with blown up compilation times when using Cranelift, which led us to consider non-optimizing Winch as a compilation backend.

There appears to be an underlying misalignment issue. Cranelift is an optimizing compiler built to produce fast code for trusted inputs, and it makes no promise to bound its own compilation cost relative to the size of the contract it’s given — internally it solves genuinely hard problems like register allocation. That’s exactly the wrong contract for an adversarial setting: anyone willing to read into the compiler can craft input that makes some phase blow up in time or memory.

The solution space

We spent a lot of the release weighing options that each solved part of the problem:

  • Increase max_block_production_delay so producers have more time before missing a slot, with a “nuclear” option in reserve: a rate limit on deployment frequency, to be rolled out only if things go wrong.
  • Increasing the compute cost for deploy actions (distinct from gas) to bound how many deploys fit in a chunk.
  • Alternative gas metering — replacing the gas check inlined at every basic-block boundary with a single function call. This shrinks instrumented code and improves compile time, at a runtime cost we measured as acceptable.
  • Winch, Wasmtime’s single-pass backend — a simpler and faster alternative to Cranelift.
  • Async / deferred compilation: move compilation off the critical path entirely. It solves the issue at its root but is far too large a feature to mix in with the VM upgrade.

Winch had some feature gaps: it didn’t support reference types or float NaN-canonicalization, both of which we need for deterministic consensus. Rather than work around them, we upstreamed the fixes to Wasmtime directly — and the payoff was dramatic. Median compile speedup over Cranelift of ~6×; the crafted 7.6s Cranelift’s worst case dropped to 36 ms (~208×); no real contract compilation exceeded 300 ms. Huge thanks to the Wasmtime maintainers at the Bytecode Alliance here — they were quick, responsive, and genuinely collaborative in reviewing and landing these changes, which is a large part of why upstreaming was a viable route at all.

Adopting Winch (and the fixes we needed) meant moving to a recent Wasmtime, which in turn required a newer Rust toolchain. That was convenient timing: nearcore had been pinned to an old Rust because newer versions emit bulk-memory wasm ops that our tooling couldn’t strip — and this very release enables bulk-memory (and reference types) at the protocol level, removing the reason for the pin. So the VM change unblocked the long-deferred Rust upgrade, and we chose to bundle both into 2.12 rather than stage them across releases.

In the end we shipped with Winch as the compiler backend, paired with the protocol-level throttles on deployments: a ~100× increase in the compute cost of a deploy action, a ~4× per-byte compute cost (so a single 4 MB contract fills a chunk), and a cut of the per-receipt deploy limit from 100 to 10.

Forknet testing

We validated all of this on forknet — a shadow of mainnet seeded from a real block height, replaying traffic, taken through the protocol upgrade, and then hammered with adversarial deploy loads while we swept max_block_production_delay from 30 s down to mainnet’s 1.8 s (the ceiling a producer waits before proceeding without full approvals; the normal block cadence is ~0.6 s). Two long-standing, load-sensitive bugs surfaced:

  1. A thread-pool deadlock with Wasmtime. Wasmtime compiles in parallel using Rayon internally — and so did our own compilation and pipelining. Nested Rayon-on-Rayon work-stealing could hand a worker a second compile task for a contract whose (non-reentrant) compilation lock it already held, deadlocking it against itself. We fixed it by moving compilation and pipelining off Rayon onto a dedicated native OS thread pool.

  2. An optimistic-block / GC race. A memtrie state root could be garbage-collected while an optimistic or fork chunk application that needed it was still in flight, panicking with StorageInconsistentState. It predated this release — heavy compilation just widened the window enough to hit it reliably — and the fix pins the previous state root in memtrie (a refcounted MemTrieRootPin held for the duration of the queued apply) so it can’t be evicted while an application that still needs it is in flight.

After the fixes, the chain rode out adversarial deploy spam across all shards through the whole block-time sweep: degraded throughput, manageable missed chunks, no crashes and no stalls.

Shipping it

Went (surprisingly) smoothly.

By the numbers

Execution (production shadow comparison, Wasmtime vs NearVM):

Metric NearVM Wasmtime
Median per-call ~2 ms ~500 µs
p99 per-call ~100 ms ~20 ms

Outcomes were identical except for gas differences under 0.002%.

Compilation (single contract, high-end hardware):

Backend Worst real contract Crafted worst (Cranelift) case Notes
NearVM ~100 ms ~9 ms baseline
Cranelift (rejected) ~600 ms ~7.6 s ~20× slower than NearVM
Winch (shipped) ~300 ms ~36 ms ~6× faster than Cranelift

The crafted case is a contract deliberately shaped to be slow to compile — it barely registers on NearVM, wrecks Cranelift, and is tamed by Winch. Winch is still slower than NearVM in absolute terms, but comfortably inside a block.

Guardrails shipped at protocol 84:

  • Wasmtime with the Winch backend; reference-types + bulk-memory enabled.
  • Deterministic limits:
    • function body ≤ 192 KB
    • instrumented code ≤ 16 MB
    • blocks per function ≤ 5,000
    • blocks per contract ≤ 50,000
    • parameters per function ≤ 64
    • types per contract ≤ 1,024
    • operand stack ≤ 8 KB per function
  • Deployment throttles: deploy compute cost ~100×, per-byte deploy compute ~4× (a 4 MB contract fills a chunk), and ≤ 10 deploy actions per receipt (down from 100).
  • max_block_production_delay left unchanged at 1.8 s.

What’s next

Wasmtime is in, but the compile-time story isn’t finished — we shipped the pragmatic version and kept the ambitious work on the roadmap:

  • Alternative gas metering — replace inlined instrumentation with a single call, for faster compilation, faster execution, and smaller artifacts; and explore a hybrid that leans on Wasmtime’s native “fuel” where it is expressive enough.
  • Compilation sandboxing — move compilation into a separate process so a panic or OOM in the compiler can’t take down neard.
  • Async and compilation — get compilation off the critical path entirely, the real long-term fix for the whole attack class.
  • Re-pricing gas — compilation now costs more and execution costs somewhat less; both should eventually be reflected.
  • Deterministic Compilation - run the compilation in the Wasmtime VM. That would enable us to use gas metering for deterministic caps on compilation time/complexity the same way we use it to cap execution.

The broader lesson is: a general-purpose tool adopted into an adversarial, deterministic, latency-bound setting will surface every assumption it quietly made. Moving off a VM we maintained ourselves was the right call — but “off-the-shelf” still meant months of making the shelf safe for a public blockchain.