All posts

WASM Contract Upgrades

13 min read · ComputeFlux Team
Runtime Infrastructure

In one sentence. ComputeFlux can change the network's rules and rewrite all the existing data to match, inside a single block, with no downtime and no fork.

Picture it like this. Renovating a bank without closing it. The industry's usual trick is to put a sign in the lobby redirecting customers to a new building — but every existing file stays behind in the old one, in the old filing system, and nothing checks whether the new staff can still read them. ComputeFlux moves the building and the files in one motion that either completes entirely or is treated as never having happened.

Why it matters. Immutability is what makes a blockchain trustworthy and also what makes it dangerous: a bug discovered on Tuesday is still there on Friday. Every serious network needs an answer to this. Most published answers are weaker than they appear, and this article is partly about why.

Curious about the sandbox rather than the upgrade mechanics? Skip to why wazero.


Smart contract upgrade is the hardest problem in blockchain engineering that nobody talks about enough. The immutability that gives blockchains their trust properties is also their biggest operational liability: once deployed, code cannot change.

The Ethereum ecosystem's answer is the proxy pattern — a thin delegate-call contract pointing at a replaceable implementation. It makes code mutable and ignores state migration entirely. Change the implementation's storage layout and the proxy's state becomes a minefield of bytes deserialized into the wrong fields.

ComputeFlux takes a different path: versioned contract routing through a dual Go-Native/WASM execution engine, with data migration executed atomically inside the consensus state transition. That atomicity comes straight from the contract storage layer described in Article 2.

Why Proxy Patterns Fail for State Migration

The standard proxy pattern works like this. A Proxy contract holds all state and delegates every call to an Implementation contract via delegatecall. Upgrading means pointing the proxy at a new implementation address.

The catch is the storage layout: unstructured, indexed by raw 256-bit slot numbers, and required to be identical between old and new implementations. Otherwise the wrong data gets read into the wrong logical fields. The Solidity compiler emits storage layout metadata, but nothing enforces compatibility at the protocol level. Add a field to a struct, or reorder its members, and you have introduced a silent, catastrophic bug that may not surface until someone reads that slot months later.

Worse, the proxy pattern provides no mechanism for transforming existing state into the new format. If version 1 stores balance as a uint128 and version 2 needs uint256, every existing balance must be migrated. Proxy-pattern projects typically handle this through off-chain scripts or one-shot migration contracts that execute with elevated privileges. Both approaches break the atomicity guarantee that blockchain state transitions normally provide: the migration is not part of the consensus state transition, so a mid-migration crash leaves the chain in an undefined intermediate state.

ComputeFlux treats migration as a first-class consensus operation instead. The contract execution path branches on a version number held in on-chain state. When a governance-approved upgrade increments that version, the next block's FinalizeBlock phase notices and runs the Migration function for the target version before any user transaction executes.

The migration runs in the same database transaction as every other state mutation in that block — the same guarantee Article 2's IndexedStore and IndexedList rely on for index consistency. So it inherits the same all-or-nothing outcome: the entire block commits with the migration included, or it rolls back completely. There is no partial migration state for anyone to recover from.

The Version Routing Architecture: Why Not Always WASM?

Given that WASM provides isolation, determinism, and hot-swappable bytecode, a natural question is why ComputeFlux maintains any Go Native contract path at all. The answer is a three-way trade-off between execution overhead, attestation integrity, and development velocity.

Performance Quantification

Every WASM call through wazero instantiates a module, copies arguments across the host-guest boundary, invokes an exported function, and copies return values back. That adds roughly 0.8–1.2μs per call on top of the contract logic itself. A Go Native call is a direct function pointer invocation: 80–120ns.

For one call, the gap vanishes against milliseconds of consensus and network latency. Scale it up, though. A block processing 200 transactions, each calling 3–5 internal contract methods — a gateway settlement might touch auth, billing, and balance — accumulates 600–1,000 invocations. At 1μs of overhead each, that's 0.6–1ms of pure dispatch per block, against 60–100μs for Go Native. Neither breaks the block time. But the 10x multiplier matters inside SGX, where every microsecond of enclave execution draws on the EPC budget.

Breaking the WASM call path down further: module instantiation from cached pre-compiled bytecode runs ~200–300ns. Host function binding adds ~100–200ns per function, since each Go host function is wrapped in a closure that translates WASM calling conventions into Go types. Crossing the call boundary costs 300–500ns for argument marshaling and stack frame setup. The remaining ~200ns is amortized cleanup. That's roughly 800–1,200ns for a no-op WASM call, against 80–120ns for Go Native — which is nothing but a CALL/RET pair with register save and restore.

All of which is beside the point until you account for what actually dominates contract execution: storage I/O. A typical contract call reads 1–3 keys from PebbleDB and writes 1–2. Each read costs 5–20μs for the LSM-tree traversal, even hitting the block cache described in Article 2. One storage read is therefore 5–20x more expensive than the entire WASM dispatch overhead.

Once I/O is the bottleneck, the Native path's 10x dispatch advantage shrinks to something closer to 1.5x overall. Native matters most for contracts that compute heavily and touch storage lightly — signature aggregation (Article 3), Merkle proof verification, data serialization — where dispatch is a real fraction of execution time. For I/O-bound contracts, the two paths converge.

Attestation Integrity and the Two-Tier Trust Model

More importantly, the Native path is the canonical, audited implementation of the core system contracts: governance, authentication, gateway settlement, batch processing. These encode the protocol's security invariants, they compile into the enclave binary, and they are measured as part of MRENCLAVE during attestation (Article 1). A remote verifier can confirm the exact governance bytecode is running, down to the instruction.

WASM contracts live on-chain and load at runtime, so their bytecode is not part of the enclave measurement. They depend instead on the attested governance contract verifying the blob's blake2b hash before execution. That two-tier model gives user-deployed contracts practical security without forcing re-attestation on every deployment, while core protocol logic stays in the strictly attested tier.

The auditing implications are significant. Suppose governance itself were WASM. An attacker who exploited a governance vulnerability could replace that bytecode on-chain with a version that drains the treasury, and the enclave would execute it faithfully — because the enclave's own code, which loads and runs WASM, never changed. Only the data did.

Keeping governance Native means changing governance logic requires changing the enclave binary, and therefore MRENCLAVE. Every remote attester sees that change, and deploying it takes coordinated agreement from the whole validator set. This doesn't prevent governance attacks — Article 19 covers that from the audit side — but it makes them detectable, attributable, and reversible.

The Routing Decision

The routing decision is one version comparison. If version == NativeVersion (currently 18), the call dispatches to the pre-compiled Go module switch, branching on contract name. Otherwise the WASM engine loads the bytecode for that version, instantiates the module, and invokes the mutation entry point.

That makes the Native path a permanent, attested fallback. A bug in the WASM engine that corrupts execution for certain bytecode patterns leaves the core system contracts untouched, because they never traverse the WASM path at all.

wazero: Why Not wasmtime or wasmer?

The runtime choice came down to wazero, wasmtime, and wasmer. Wasmtime (Bytecode Alliance, Rust) and wasmer (Rust) are mature, JIT-capable, and fast. Both also depend on C libraries for their JIT compilers — Cranelift or LLVM.

Calling into C from an SGX enclave leaves two options, and both are bad. Link the C library into the enclave and you balloon the trusted computing base by millions of lines of unaudited C. Use OCALLs for WASM execution and you break the enclave boundary on every contract call, which defeats the point of running in a TEE.

wazero is pure Go with zero CGo dependencies, so every instruction its interpreter executes stays inside the enclave's protected memory. ComputeFlux runs wazero's interpreter configuration rather than its compiler configuration. Under EGo/SGX, the executable memory pages JIT-generated code needs simply aren't available the way they are on a normal host. The interpreter isn't just the more auditable option here — it's the one that runs inside the enclave without crashing.

Its lower raw speed rarely bites. Typical contract work is data serialization, integer arithmetic, and state reads and writes through host functions, and those host calls to the storage layer dominate execution time. A contract that spends most of its life waiting on storage I/O gains little from JIT compilation even where JIT is an option. Simplicity, auditability, and zero JIT attack surface round out the case beyond the SGX constraint itself.

wazero also isolates module instances automatically: each contract invocation creates a fresh module instance with its own memory, preventing cross-contract data leakage through shared linear memory — a class of bugs that has affected multi-tenant WASM runtimes elsewhere. Instances are garbage-collected after the call completes, keeping memory pressure bounded even under sustained execution.

Migration Safety Guarantees

Three properties must hold for migration to be safe in a blockchain context: atomicity, idempotency, and backward compatibility.

Atomicity comes from executing the migration function within the same database transaction as the block that activates the new version. PebbleDB's snapshot isolation (Article 2) ensures readers see either the complete pre-migration state or the complete post-migration state, never something in between. If the migration function panics or errors, the transaction rolls back and the block is rejected; the network continues on the previous version until the issue is resolved.

Idempotency is handled by an Init check in the contract routing layer: every contract has an Init method that initializes its storage layout, gated by a per-contract initialized flag, so calling Init on an already-initialized contract is a no-op. The migration function itself, running separately before user transactions, is idempotent at the application level — it checks each record's format and migrates only records still in the old format, so running it twice finds everything already migrated and changes nothing.

Backward compatibility is the hardest guarantee. Add a field to a data structure and in-flight transactions from clients on an older SDK will still arrive without it. ComputeFlux handles this with a grace period: the codec deserializing incoming transactions checks the schema version and quietly upconverts old-format data, filling defaults for the new fields. It's the same approach protobuf uses for backward-compatible evolution, applied at the application layer.

The concrete example is the V14→V15 migration Article 2 uses as a case study. V14's APIKeyInfo stored owner, key hash, rate limit, and activation status. V15 added a Prefix field for namespace-scoped API keys. The migration function walks every existing key, deserializes it in V14 format, builds a V15 struct with Prefix set to the empty string, and writes it back. A key created under V14 and never touched again ends up with an empty prefix, which V15 code paths read correctly as "no namespace restriction."

Clients on the old SDK are handled the same way. A V14 client submitting a key update won't include a Prefix field at all. The codec detects its absence from the serialized bytes — protobuf's optional-field encoding makes that deterministic — and fills in the empty string before contract logic ever sees the struct. The client gets V14 semantics without knowing V15 exists.

None of this is free. The codec has to maintain deserializers for every historical schema version and know the upgrade path between each pair. That cost is why version bumps are infrequent governance decisions rather than routine engineering.

The Native-to-WASM path adds one last wrinkle. When a core contract moves from Native to WASM, the old Native code stays in the binary — it already contributed to MRENCLAVE, and removing it would change the measurement — but the version router simply stops routing to it. The WASM bytecode becomes the only implementation, and it has to be audited to the same standard as the Native code it replaced. That is a governance responsibility, enforced off-chain.


Key Takeaways

  • The famous upgrade pattern solves the easy half and ignores the hard half. Swapping which code runs is straightforward. Converting the data that code already wrote is where projects quietly lose money, and the usual answer — an off-chain script run by an admin — abandons the all-or-nothing guarantee that made the chain trustworthy in the first place.
  • Here, migration is a block. The data conversion runs inside the same transaction as everything else in that block. Either it all lands or none of it did. There is no half-migrated state anyone has to clean up at 4am.
  • Two tiers of trust, deliberately. Core contracts — governance, billing, authentication — are baked into the enclave binary, so hardware attestation vouches for them directly. User-deployed contracts run in a sandbox, loaded from chain data. Flexible, but honestly one tier down.
  • That split exists because of a specific attack. If governance itself were swappable bytecode, anyone who compromised governance could substitute code that drains the treasury, and the enclave would run it faithfully — the code never changed, only the data did. Keeping governance in the attested tier makes any such change visible to every observer on the network.
  • The enclave dictated the sandbox choice. The fast, industry-standard runtimes need either C libraries or writable-executable memory, and neither is available inside SGX. The pure-Go interpreter is theoretically slower and practically comparable, because storage reads dominate anyway.

Contract upgrades are where ComputeFlux's storage design (Article 2) and consensus atomicity (Article 4) meet in practice: a migration is just a state transition that happens to rewrite existing data instead of processing a new transaction.

With the contract layer covered, the series turns outward — from how the network is built to what it actually does for a living.

Next — Article 6: Provider & Model Registry: who gets to sell AI capacity on this network, what it costs them to lie, and how the answer becomes a routing decision.