Verifying upgradeable smart contracts is a critical security practice that extends beyond standard deployment checks. Unlike immutable contracts, upgradeable systems involve multiple components: a proxy contract that delegates calls, an implementation contract containing the logic, and often a proxy admin. Formal verification ensures the integrity of the entire system, confirming that the proxy correctly delegates to the intended implementation and that upgrades do not introduce storage collisions or broken invariants. This process is essential for protocols managing user funds or critical state.
Setting Up a Formal Verification Process for Upgradeable Contracts
Introduction to Verifying Upgradeable Contracts
A systematic guide to establishing a formal verification process for smart contracts that use upgradeable patterns like Transparent Proxies or UUPS.
The verification process begins with a clear specification. This involves defining the contract's intended behavior in a formal language, such as the properties it must always uphold (invariants) and the expected outcomes of specific functions (post-conditions). For upgradeable contracts, key specifications include: the proxy's admin can be updated only by the current admin, the implementation address can only be changed via a designated upgrade function, and user storage slots are preserved across upgrades. Tools like Certora Prover or SMTChecker use these specifications to mathematically prove the code's correctness.
Setting up the environment requires specific tooling. For Solidity contracts using OpenZeppelin's upgradeable libraries, you must verify both the proxy and implementation separately on block explorers like Etherscan. However, formal verification tools require a different setup. You typically write verification rules in a specification language (e.g., Certora's CVL) that references your Solidity code. The tool then symbolically executes all possible paths through the code to check for rule violations. A common practice is to run these checks in CI/CD pipelines, triggering verification on every pull request that modifies core contract logic.
A critical focus area is storage layout compatibility. When an implementation is upgraded, its new storage variables must be appended to the existing layout to prevent catastrophic data corruption. Your verification process must include rules that enforce this. For example, a specification can assert that the order and types of state variables in a new implementation are a superset of the previous version's, with no modifications to existing variable slots. The OpenZeppelin Upgrades Plugins perform runtime checks for this, but formal verification provides mathematical certainty.
Finally, document and maintain your verification results. Each verified property should have a clear pass/fail status and a link to the formal report. Integrate these findings into your audit trail. For teams using Foundry, you can incorporate formal verification via forge verify with the --via-ir flag and SMT solver checks. This creates a repeatable, automated process that significantly reduces the risk of introducing critical bugs during an upgrade, providing stakeholders with verifiable proof of the system's security posture.
Setting Up a Formal Verification Process for Upgradeable Contracts
A structured approach to verifying the correctness and safety of smart contract upgrades before deployment.
Formal verification is the process of mathematically proving that a smart contract's code satisfies a formal specification of its intended behavior. For upgradeable contracts, this process is critical to ensure that new implementations do not introduce security vulnerabilities or break core functionality. The setup involves three core components: the target contract (the upgradeable proxy and its logic), a formal specification (properties to verify), and a verification tool like Certora Prover, Halmos, or Foundry's forge verify-contract. You will need a development environment with Solidity, a testing framework, and access to the chosen verification engine.
Begin by establishing a clear specification. This is a set of invariants—properties that must always hold true across all possible states and transactions. For an upgradeable ERC-20 token, key invariants might include: the total supply must remain constant unless mint/burn functions are called, user balances are never negative, and the owner role cannot be changed by unauthorized addresses. Write these properties as formal rules in the language your tool requires, such as Certora's CVL or Solidity assertions for symbolic execution tools. This specification acts as the benchmark for all future logic upgrades.
Next, integrate the verification tool into your development workflow. For example, with Foundry, you can write invariant tests in Solidity using the forge test --match-test invariant command, which uses symbolic execution to check properties. For more comprehensive verification, tools like the Certora Prover require a separate rule file. The setup typically involves configuring a connection to the prover's service, specifying the contract's Solidity files and compilation artifacts, and defining the rules file. Run an initial verification on your base implementation to establish a verification baseline.
The final prerequisite is to create a verification pipeline that runs automatically. This should be integrated into your CI/CD system (e.g., GitHub Actions) and triggered on every pull request that modifies the contract logic. The pipeline must compile the new implementation, link it with the proxy storage layout, and run the full suite of formal verification rules against the upgraded system. Any rule violation must fail the build, preventing the deployment of unsafe code. This automated gate ensures that the security properties proven for the original contract are preserved through every upgrade cycle.
Setting Up a Formal Verification Process for Upgradeable Contracts
A systematic guide to implementing formal verification for smart contract upgrade systems to ensure correctness and security.
Formal verification for upgradeable contracts requires proving that the state transitions between contract versions are safe and preserve intended invariants. Unlike verifying a single contract, you must model the entire upgrade lifecycle, including the proxy, admin, and implementation logic. Key properties to verify include state variable compatibility (no storage collisions), function selector uniqueness (no shadowing), and the preservation of critical invariants (like total supply in a token) across all versions. Tools like Certora Prover and K-Framework are designed for this multi-contract analysis.
The first step is to define the specification or formal properties that must hold. For an upgradeable ERC-20 token, this includes properties like totalSupply never decreasing on a transfer, or that the owner role can only be modified by specific admin functions. You must write these specifications in the tool's domain-specific language (e.g., Certora's CVL). Crucially, specifications must account for the proxy's delegatecall mechanism, ensuring the logic contract's behavior is correctly mediated through the proxy's storage context.
A major challenge is modeling the upgrade mechanism itself. You must verify that the upgrade function (e.g., upgradeTo) correctly points the proxy to a new implementation address and that this action doesn't violate any system invariants. This involves writing rules that consider the pre-upgrade and post-upgrade states. For example, a rule might assert: "After an upgrade, all user balances from the previous implementation must remain accessible and unchanged." This prevents a faulty upgrade from corrupting or losing user data.
Integrating formal verification into your development workflow is essential. It should run automatically in CI/CD pipelines on every pull request that modifies core contracts or upgrade logic. Use tools like Slither or Foundry for preliminary static analysis and fuzzing, then run the formal verification suite. A failed verification must block the merge. Document all verified properties and assumptions in your code repository to provide auditors and developers with a clear understanding of the contract's guaranteed behavior.
Finally, remember that formal verification is a complement to, not a replacement for, other security practices. It proves your code adheres to a specific mathematical specification, but that specification must be complete and correct. Combine it with thorough testing (unit, integration, fuzzing), audits, and bug bounties. For teams new to formal methods, start by verifying a single critical property, like the safety of a funds withdrawal function, before scaling to the entire upgrade system.
Tools for Verifying Upgradeable Code
Formal verification uses mathematical proofs to guarantee smart contract correctness. For upgradeable contracts, this process must account for storage layout, initialization logic, and upgrade safety.
Writing Formal Specifications
The core of formal verification is writing precise specifications. For an upgradeable ERC-20 contract, a specification might assert:
- Total supply invariance:
totalSupply() == sum(balances) - Access control:
onlyOwnerfunctions are never callable by user addresses - Upgrade safety: After an upgrade, all user balances are preserved
Tools like Certora use a custom language (CVL), while Halmos uses Solidity
assertstatements. The key is defining the invariants that must hold before and after any upgrade.
Process: Integrating Verification into Development
A practical verification workflow for upgradeable contracts:
- Define Specifications: Document invariants (e.g., "user funds are safe").
- Static Analysis: Run Slither to catch basic upgradeability errors.
- Symbolic Testing: Use Halmos to test properties on Foundry test cases.
- Formal Verification: Encode key invariants in Certora Prover for mathematical proof.
- Version Control: Store specifications alongside the contract code. Re-run verification for every proposed upgrade to ensure new logic complies with all proven rules.
Verification Considerations by Upgrade Pattern
Key formal verification factors for common smart contract upgrade architectures.
| Verification Aspect | Transparent Proxy (OpenZeppelin) | UUPS (EIP-1822) | Diamond Standard (EIP-2535) |
|---|---|---|---|
Initialization Logic Verification | |||
Storage Layout Invariant Proof | |||
Upgrade Function Reentrancy | Not applicable | Critical | Critical |
Selector Clashing Analysis | Not applicable | Not applicable | Critical |
Implementation Address Consistency | Per-facet | ||
Gas Overhead for Verification | Low | Medium | High |
Tooling Support (e.g., Certora, Halmos) | Full | Partial | Experimental |
Formal Specification Complexity | Low | Medium | High |
Step 1: Specifying Cross-Upgrade Invariants
The first step in verifying an upgradeable smart contract system is to define the properties that must hold true across all versions of the contract.
A cross-upgrade invariant is a logical property that must remain valid before and after any upgrade to your smart contract's implementation. Unlike standard invariants that apply to a single contract state, these properties are concerned with the relationship between the old and new versions. For example, a critical invariant for an ERC-20 token contract would be: "The total supply of tokens must not increase after an upgrade, except via the designated minting function." This prevents a malicious or buggy upgrade from arbitrarily inflating the token supply.
To specify these invariants effectively, you must identify the core protocol guarantees that are non-negotiable. Focus on state variables and functions that define the contract's essential value and security. Common categories include: - Preservation of user balances (e.g., in a vault or staking contract). - Immutability of access control roles and ownership. - Conservation of total value locked (TVL) in a pool. - Unchangeability of critical constants like fee denominators or reward rates. Write each invariant as a clear, testable logical statement using the variables and functions of your contract.
Formal verification tools like Certora Prover or Solidity SMTChecker require these invariants to be expressed in a formal specification language. For instance, in Certora's CVL, you might write a rule: rule totalSupplyNonIncreasing with the logic old(totalSupply) <= totalSupply. This instructs the prover to check that the totalSupply state variable never decreases from its pre-upgrade value. The precision of this specification is crucial; ambiguous natural language statements cannot be verified by automated tools.
A practical approach is to audit the contract's upgrade function, typically upgradeTo(address newImplementation), and enumerate all possible state transitions it could allow. Consider what the new implementation's initialize or migration function can do. Your invariants must hold even if the new code attempts to modify storage directly. This step forces a rigorous security review of the upgrade mechanism itself, often revealing assumptions about storage layout compatibility that should be encoded as formal properties.
Finally, document these invariants alongside the contract code, perhaps in NatSpec comments or a separate specification file. This creates a single source of truth for developers, auditors, and the protocol community about the system's fundamental guarantees. When a proposed upgrade is submitted, its verification report must demonstrate that all specified cross-upgrade invariants are preserved, providing mathematical certainty for a critical security property.
Step 2: Verifying Storage Layout Compatibility
This step ensures your new contract version can safely inherit the state of the previous deployment by analyzing and comparing their storage structures.
Storage layout incompatibility is a primary cause of critical upgrade failures. When you deploy a new implementation contract, the proxy's storage pointer is updated, but the actual data in the contract's storage slots remains. If the new contract's variable layout does not match the old one's, the new logic will read and write data from the wrong slots, leading to corrupted state and potential loss of funds. This verification step is non-negotiable for any upgrade involving state changes.
The core principle is that each state variable is assigned a specific storage slot based on its declaration order and type. For example, a uint256 variable occupies a full 32-byte slot, while multiple smaller uint128 variables can be packed into a single slot. You must verify that for all existing variables, their slot position, offset, and type remain identical in the new contract. Adding new variables is generally safe if appended at the end, but inserting or removing variables changes the layout for all subsequent ones.
To perform this verification, use the @openzeppelin/upgrades-core package. After compiling your new contract, run the validateUpgrade function from the plugin for your framework (e.g., Hardhat-Upgrades). This tool compares the storage layouts of the old and new implementations. It will output a detailed report highlighting any dangerous changes, such as:
- Type change: Changing a
uint256to aaddress. - Slot change: Reordering variable declarations.
- Packing conflict: Altering packing within a storage slot.
For manual inspection or custom tooling, you can generate and compare the storage layout JSON files produced by the Solidity compiler using the storage-layout output selection. Examine the storage array in each file. Each entry contains the label (variable name), slot (as a hex string), offset (for packed variables), and type. A safe upgrade requires every entry from the old layout to have a matching entry in the new layout with identical slot, offset, and type properties.
Consider this unsafe change. Original contract: address owner; uint256 balance;. New contract: uint256 balance; address owner;. The variables are swapped. The owner value will now be read from slot 0 (where balance is stored) and vice-versa, causing the contract to interpret the balance number as an address. The validation tool will flag this as a critical error. Always run the verification in a CI/CD pipeline and treat any storage layout warnings as blockers for deployment.
Code Patterns for Verifiable Upgrades
A guide to implementing formal verification for upgradeable smart contracts, ensuring correctness and security across versions.
Formal verification mathematically proves a smart contract's code satisfies its specifications, a critical step for upgradeable systems. Unlike standard testing, which finds bugs, formal verification aims to prove their absence for specific properties. For upgradeable contracts, this process must be applied to both the proxy/upgrade mechanism and the implementation logic to ensure new versions don't introduce regressions or violate core invariants. Tools like the Certora Prover or Foundry's formal verification capabilities are commonly used for this task.
The first step is defining the formal specification, or "rules," that your contract must always obey. These invariants are properties that hold true across all possible states and transactions. For an upgradeable ERC-20 token, key invariants might include: the total supply equals the sum of all balances, balances are never negative, and the owner role cannot be transferred by a non-owner. You write these rules in a specification language (like Certora's CVL) that the prover understands. A well-defined spec is the foundation of the entire verification process.
Next, you must verify the upgrade mechanism itself. For a Transparent Proxy Pattern using OpenZeppelin, you need to prove that: the upgradeTo function can only be called by the proxy admin, the implementation address is correctly updated in storage, and delegatecall to the new implementation preserves the proxy's storage layout. For a UUPS (EIP-1822) upgradeable contract, you must also verify that the upgrade function is present and secure within the implementation logic. Missing this step leaves the door open for an attacker to hijack the proxy.
When a new implementation is ready, you run the prover against the updated code using the same set of specifications. The tool will attempt to find a counterexample—a sequence of transactions that breaks a rule. If it succeeds, you have a bug. If all rules are proven, you gain high confidence the upgrade is safe. It's crucial to version-control your specifications alongside your contract code and integrate formal verification into your CI/CD pipeline using scripts or GitHub Actions.
Consider a practical example: verifying a vault contract's safety during an upgrade. A core invariant is totalAssets() <= vaultBalance. Your specification would formalize this. After modifying the deposit function in a new implementation, the prover might reveal a scenario where a flash loan could break this invariant, which unit tests missed. Fixing this before deployment prevents a critical vulnerability. This demonstrates how formal verification catches complex, state-dependent bugs that are otherwise elusive.
Formal verification is not a silver bullet; it proves adherence to your specified rules, not general "correctness." It requires significant expertise and can be computationally expensive. However, for upgradeable contracts managing high-value assets, it is an indispensable part of a robust security toolkit, complementing audits and testing. Start by verifying critical invariants for your most important contracts and expand coverage over time.
Frequently Asked Questions
Common questions and troubleshooting steps for implementing a formal verification process for upgradeable smart contracts.
Formal verification is a mathematical method for proving or disproving the correctness of a smart contract's logic against a formal specification. For upgradeable contracts, this process is applied to both the proxy and implementation logic, as well as the upgrade mechanism itself. The goal is to mathematically guarantee that:
- The proxy correctly delegates all calls to the implementation.
- The upgrade function only allows authorized actors.
- Storage layouts remain compatible between versions.
- No state corruption occurs during or after an upgrade. Tools like the K framework or Certora Prover are used to model the EVM and the contract's behavior, generating formal proofs that certain critical properties (e.g., "only the owner can upgrade") hold for all possible execution paths.
Resources and Further Reading
These tools and references help teams design, specify, and verify upgradeable smart contracts using formal and semi-formal methods. Each resource focuses on preventing upgrade-specific failures such as storage corruption, broken invariants, and unauthorized logic changes.
Conclusion and Next Steps
Formal verification is a critical, ongoing discipline for secure upgradeable smart contracts. This guide has established the foundational steps. Here’s how to solidify and scale your process.
You have now established a formal verification workflow for your upgradeable contracts. The core steps are: defining invariants in a specification language like Certora's CVL or Scribble, running the verifier against your contract's bytecode, and interpreting counterexamples to fix bugs. This process should be integrated into your CI/CD pipeline, triggered on every pull request to the main branch. Tools like the Certora Prover or the Solidity SMTChecker provide the automation backbone. The goal is to make formal verification a mandatory gate, not an optional audit.
The next phase involves expanding your verification coverage. Start by adding more complex protocol invariants. For a lending protocol, verify that a user's health factor cannot improve during a liquidation. For a DEX, prove that pool reserves are always non-negative. Then, model upgrade-specific properties. A crucial invariant is that a UUPSUpgradeable implementation's _disableInitializers function must be called in the constructor to lock it. Another is that an upgrade via a TimelockController cannot violate a pre-defined safety property encoded in the timelock's proposal.
To deepen expertise, engage with the formal verification community. Review public verification reports for major protocols like Aave or Compound on the Certora Resources page. Study the CVL rule writing workshops available on GitHub. For complex cross-contract interactions, you may need to write harness contracts that abstract external dependencies. Remember, the verifier is only as good as the properties you define. Continuously refine your specifications as your protocol logic evolves.
Finally, formal verification complements but does not replace other security practices. It excels at proving the absence of entire classes of bugs related to your invariants. You must still conduct manual audits, fuzz testing with tools like Echidna, and static analysis. A robust security posture layers these techniques. By institutionalizing formal verification, you shift security left in the development lifecycle, catching critical flaws before they are deployed, and building verifiable trust for users and stakeholders.