What Is Compile-Time Verification for Smart Contracts?
Compile-time verification proves that a smart contract satisfies its declared properties during compilation, before the contract is deployed or any transaction is executed. The compiler analyzes each state transition, extracts proof obligations from the declared invariants, and discharges them through algebraic reasoning. Once an invariant is proven, its annotation is erased from the output binary. The resulting WASM contains only the transition logic with zero runtime verification overhead. This is categorically stronger than runtime checks, which detect violations only after they occur and cost gas on every transaction regardless of whether a violation is possible.
Three Stages of Verification
Smart contract properties can be verified at three different stages. Each stage offers different tradeoffs between when bugs are caught, what guarantees are provided, and what runtime cost is incurred.
| Stage | When | Guarantee | Runtime Cost |
|---|---|---|---|
| Compile time | During build | Mathematical proof for all inputs and states | Zero (proven properties erased) |
| Runtime | During execution | Transaction reverts on violation | Gas per check per transaction |
| Post-deployment | After deployment | Detection of violations after the fact | Monitoring infrastructure cost |
Compile-time verification catches bugs before the contract is deployed. The proof covers all possible inputs, all possible states, and all possible sequences of transactions. If the contract compiles, the invariant holds universally. There is no gas cost because the proven property is erased from the output.
Runtime verification catches violations during transaction execution. In Solidity, this takes the form of require statements, assert statements, and modifier checks. Each check executes on every transaction that reaches it, consuming gas. If the check fails, the transaction reverts. The user pays gas for the failed computation, and the contract is left in its pre-transaction state. Runtime checks are necessary for conditions that depend on external inputs (such as whether the caller has sufficient balance), but they are redundant for properties that can be proven statically.
Post-deployment verification monitors the deployed contract for anomalous behavior. Tools like Forta, Tenderly, and OpenZeppelin Defender watch transactions and emit alerts when suspicious patterns are detected. This is useful for catching issues that neither compile-time nor runtime verification can address, such as oracle manipulation, MEV attacks, or governance anomalies. However, by the time a post-deployment monitor fires, the damage may already be done.
Why Catching Bugs at Compile Time Is Categorically Better
The difference between compile-time and runtime verification is not just earlier detection. It is a difference in kind.
A runtime check says: "if this property is violated, revert." A compile-time proof says: "this property cannot be violated." The runtime check implies the violation is possible and provides a recovery mechanism. The compile-time proof eliminates the violation from the space of possible behaviors. There is nothing to recover from because the bad state cannot be reached.
This matters because smart contracts are immutable once deployed. A runtime check that reverts prevents damage in the specific transaction, but the vulnerability remains in the contract code. An attacker can keep trying different inputs and sequences until they find one that bypasses the check or exploits a different path. A compile-time proof closes the entire class of vulnerability permanently.
Invariant Erasure: Zero Runtime Cost
When the Formagine compiler proves that an invariant holds across all transitions, it erases the invariant annotation from the intermediate representation before generating WASM. The final binary contains no assertion instructions, no conditional branches for invariant checks, and no error handling code for invariant violations.
This is sound because the compiler has already demonstrated that no execution path can violate the invariant. Checking it at runtime would be redundant. Erasing it produces a smaller binary that executes faster and consumes less gas per transaction.
-- Source Form (what you write) form Token { state { balance : Nat per Address; supply : Nat } invariant conservation : sum(balance) == supply transition transfer(from, to, amount) { require balance[from] >= amount balance[from] -= amount balance[to] += amount } } -- Compiled WASM (what deploys) -- Only the transition logic remains. -- The invariant has been proven and erased. -- No runtime check for sum(balance) == supply. -- The require clause remains: it guards a precondition, -- not a proven invariant.
Note the distinction between require clauses and invariants. A require clause is a precondition that depends on the caller's input (does the sender have enough balance?). It must remain in the runtime because the answer depends on the specific transaction. An invariant is a global property of the state space that the compiler can prove universally. Only the invariant is erased. The require clause stays.
Comparison with Solidity's Approach
Solidity provides three mechanisms for runtime checks:
require(condition, "message") checks a precondition and reverts with a message if it fails. Used for input validation and access control. Consumes gas on every call. Remaining gas is refunded on revert.
assert(condition) checks an internal invariant. If it fails, all gas is consumed (in older Solidity versions) or a Panic error is emitted (in newer versions). Intended for conditions that should never be false if the contract is correct.
modifier wraps function execution with precondition and postcondition logic. A reentrancy guard modifier, for example, sets a lock before execution and releases it after. This is runtime logic that executes on every call.
All three are runtime mechanisms. They detect problems after computation begins. They cost gas. They cannot guarantee that the condition holds for future transactions with different inputs. If you write assert(totalSupply == sumOfBalances()) in Solidity, that assertion executes and costs gas on every transaction, and it tells you nothing about whether the next transaction will also pass.
In Formagine, the equivalent property is declared as invariant conservation : sum(balance) == supply. The compiler proves it holds for all transitions. No runtime code is generated for it. Every transaction is guaranteed to preserve it, not because a check will catch violations, but because the violation is mathematically impossible.
Frequently Asked Questions
- What is the difference between compile-time and runtime verification?
- Compile-time verification proves properties before the program runs, covering all possible inputs and states. Runtime verification checks properties during execution, reverting if violated. Compile-time is stronger (no violation can occur) and cheaper at runtime (proven properties are erased). Runtime checks incur gas on every transaction and only detect violations after they happen.
- How does invariant erasure work in Formagine?
- Once the compiler proves an invariant holds across all transitions, the annotation is removed before WASM code generation. The final binary has no assertion instructions or error handling for invariant violations. This is sound because the violation has been proven impossible. The result is smaller, faster, cheaper contracts.
- Does compile-time verification increase compilation time?
- The algebraic verification pass operates on the structure of transitions, not on individual states or paths. For typical Forms, it completes in milliseconds. Total compilation including verification is typically under one second.
- How does this compare to Solidity's require statements?
- Solidity's
requireis a runtime check that executes on every transaction, costs gas, and only catches violations after computation begins. Compile-time verification proves the property before deployment. There is no gas cost, no failed transaction, and no possibility of violation. The tradeoff is that compile-time verification requires the property to be algebraically expressible.