2026-02-25·14 min read

Fuzzing upgradeable contracts: storage layout, proxy safety, and upgrade invariants

Fuzzing Upgradeable Contracts: Storage Layout, Proxy Safety, and Upgrade Invariants

By kn0t

Upgradeable contracts are a double-edged sword. They let you fix bugs in production — but they also let you introduce entirely new categories of bugs that don't exist in non-upgradeable contracts. Storage collisions, initializer re-entrancy, broken upgrade paths. These aren't theoretical. Wormhole lost $320M partly because of a proxy-related issue. Audius lost $6M from an unguarded initializer.

I'm going to walk through every property you need to test upgradeable contracts. We'll cover UUPS, TransparentProxy, and Beacon patterns. Let's get into it.

Storage layout preservation

This is the single most important property for upgradeable contracts. When you upgrade from Implementation V1 to V2, every storage slot that existed in V1 must mean the same thing in V2. If V2 reorders variables or inserts a new one before existing ones, you'll get silent data corruption.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {Test} from "forge-std/Test.sol";

contract UpgradeInvariantTest is Test {
    TransparentUpgradeableProxy proxy;
    ImplementationV1 implV1;
    ImplementationV2 implV2;
    UpgradeHandler handler;

    function setUp() public {
        implV1 = new ImplementationV1();
        proxy = new TransparentUpgradeableProxy(
            address(implV1),
            admin,
            abi.encodeWithSelector(ImplementationV1.initialize.selector)
        );
        implV2 = new ImplementationV2();
        handler = new UpgradeHandler(proxy, implV1, implV2);
        targetContract(address(handler));
    }

    /// @notice Storage slots must preserve meaning across upgrades
    function invariant_storage_layout_preserved() public {
        // Read critical storage slots before upgrade
        bytes32 slot0Before = vm.load(address(proxy), bytes32(uint256(0)));
        bytes32 slot1Before = vm.load(address(proxy), bytes32(uint256(1)));
        bytes32 slot2Before = vm.load(address(proxy), bytes32(uint256(2)));

        if (handler.upgradePerformed()) {
            // Same slots should contain same semantics
            // We verify by calling getter functions that read those slots
            ImplementationV2 proxyV2 = ImplementationV2(address(proxy));

            // V1's owner slot must still be owner in V2
            assertEq(
                proxyV2.owner(),
                handler.ownerBeforeUpgrade(),
                "Owner changed after upgrade"
            );

            // V1's totalSupply slot must still be totalSupply in V2
            assertEq(
                proxyV2.totalSupply(),
                handler.totalSupplyBeforeUpgrade(),
                "Total supply corrupted after upgrade"
            );

            // V1's mapping root must still map correctly in V2
            for (uint256 i = 0; i < handler.trackedUserCount(); i++) {
                address user = handler.trackedUsers(i);
                assertEq(
                    proxyV2.balanceOf(user),
                    handler.balanceBeforeUpgrade(user),
                    "User balance corrupted after upgrade"
                );
            }
        }
    }
}

The trick here is tracking values before the upgrade and comparing them after. Your handler needs to snapshot all critical state before performing the upgrade.

Initializer safety

Initializers replace constructors in upgradeable contracts. The bug: if initialize() can be called twice, an attacker can reset the contract's state. This is what hit Audius — an attacker called an unguarded initializer to take over the proxy.

/// @notice Initialize can only be called once
function invariant_initializer_only_once() public {
    if (!handler.contractInitialized()) return;

    // Try to re-initialize -- it must revert
    try ImplementationV1(address(proxy)).initialize() {
        assertTrue(false, "Re-initialization succeeded");
    } catch {
        // Expected -- initializer is locked
    }
}

/// @notice Implementation contract itself must be initialized
/// (prevents direct calls to the implementation)
function invariant_implementation_initialized() public {
    // The implementation behind the proxy should also be initialized
    // to prevent attacks that call the implementation directly
    try implV1.initialize() {
        assertTrue(
            false,
            "Implementation contract can be initialized directly"
        );
    } catch {
        // Expected
    }
}

That second property catches a subtle bug. Even if the proxy's storage has the initializer locked, the implementation contract itself might not be initialized. An attacker can call initialize() on the implementation directly, take ownership, and then call selfdestruct (pre-Dencun) to brick all proxies pointing to it.

Proxy-implementation consistency

The proxy and implementation must agree on the admin, the implementation address, and the interface they expose.

/// @notice Proxy must point to a valid implementation
function invariant_implementation_is_contract() public view {
    address impl = handler.getCurrentImplementation();
    uint256 codeSize;
    assembly {
        codeSize := extcodesize(impl)
    }
    assertGt(codeSize, 0, "Implementation is not a contract");
}

/// @notice Admin slot must contain the correct admin address
function invariant_admin_consistency() public view {
    // EIP-1967 admin slot
    bytes32 adminSlot = bytes32(
        uint256(keccak256("eip1967.proxy.admin")) - 1
    );
    bytes32 storedAdmin = vm.load(address(proxy), adminSlot);
    address adminAddress = address(uint160(uint256(storedAdmin)));

    assertEq(
        adminAddress,
        handler.expectedAdmin(),
        "Admin slot corrupted"
    );
}

/// @notice Implementation slot must contain the correct implementation
function invariant_implementation_slot_correct() public view {
    // EIP-1967 implementation slot
    bytes32 implSlot = bytes32(
        uint256(keccak256("eip1967.proxy.implementation")) - 1
    );
    bytes32 storedImpl = vm.load(address(proxy), implSlot);
    address implAddress = address(uint160(uint256(storedImpl)));

    assertTrue(
        implAddress == address(implV1) || implAddress == address(implV2),
        "Implementation slot points to unknown contract"
    );
}

Admin access control for upgrades

Only authorized addresses should be able to trigger an upgrade. This seems obvious, but the check differs between UUPS and TransparentProxy patterns.

/// @notice Only admin can upgrade (TransparentProxy)
function invariant_upgrade_authorization() public view {
    if (handler.upgradeAttempts() > 0) {
        for (uint256 i = 0; i < handler.upgradeAttempts(); i++) {
            UpgradeAttempt memory attempt = handler.getUpgradeAttempt(i);
            if (attempt.succeeded) {
                assertEq(
                    attempt.caller,
                    handler.expectedAdmin(),
                    "Non-admin successfully upgraded"
                );
            }
        }
    }
}

/// @notice UUPS: upgradeToAndCall must check authorization
function invariant_uups_authorization() public {
    address randomUser = makeAddr("randomUser");
    vm.prank(randomUser);
    try UUPSUpgradeable(address(proxy)).upgradeToAndCall(
        address(implV2),
        ""
    ) {
        assertTrue(
            false,
            "Unauthorized upgrade succeeded on UUPS proxy"
        );
    } catch {
        // Expected -- only authorized upgraders can call this
    }
}

UUPS vs TransparentProxy: different threat models

With TransparentProxy, the admin is a separate ProxyAdmin contract. The risk: if the ProxyAdmin's ownership is compromised, all proxies it manages are compromised.

With UUPS, the upgrade logic lives in the implementation. The risk: if you forget to include the upgrade function in a new implementation, you can't upgrade anymore. Permanent brick.

/// @notice UUPS implementation must contain upgrade function
function invariant_uups_upgrade_function_exists() public view {
    address currentImpl = handler.getCurrentImplementation();
    // Check that the implementation has upgradeToAndCall selector
    bytes4 selector = UUPSUpgradeable.upgradeToAndCall.selector;

    // We can verify by checking the implementation's code contains
    // the selector -- or more practically, by trying to call it
    // through the proxy (which should only fail on auth, not on
    // missing function)
}

Selfdestruct protection (pre-Dencun)

Before EIP-6780 (Dencun), selfdestruct could destroy a contract and its storage. For pre-Dencun deployments, this was a real threat to proxies:

/// @notice Implementation must not have selfdestruct
/// (static analysis property -- verify in deployment pipeline)
function test_no_selfdestruct_in_implementation() public view {
    // This is better done as a static analysis check, but you can
    // verify the implementation is still alive after handler actions
    address currentImpl = handler.getCurrentImplementation();
    uint256 codeSize;
    assembly {
        codeSize := extcodesize(currentImpl)
    }
    assertGt(
        codeSize,
        0,
        "Implementation was destroyed"
    );
}

Post-Dencun, selfdestruct only sends ETH without destroying code (except in the same transaction as creation). But many contracts deployed before Dencun are still live and vulnerable.

Storage gap correctness

Storage gaps are the convention for reserving space in base contracts so derived contracts can add variables without collisions.

/// @notice Storage gaps must maintain constant total slot count
function invariant_storage_gap_size() public view {
    // V1 has 5 state variables + __gap of size 45 = 50 slots
    // V2 adds 2 variables, so __gap should shrink to 43

    if (handler.upgradePerformed()) {
        // Read the gap slot count from V2
        uint256 v2GapSize = implV2.getGapSize();
        uint256 v2VarCount = implV2.getVariableCount();

        // Total must equal V1's total
        uint256 v1Total = implV1.getVariableCount()
            + implV1.getGapSize();
        uint256 v2Total = v2VarCount + v2GapSize;

        assertEq(
            v1Total,
            v2Total,
            "Storage gap + variables changed total slot count"
        );
    }
}

In practice, I verify gaps using Foundry's forge inspect rather than on-chain checks. But this invariant catches the case where someone modifies the gap incorrectly during an upgrade.

Building the upgrade handler

Your handler needs to simulate the full upgrade lifecycle:

contract UpgradeHandler is CommonBase, StdCheats, StdUtils {
    bool public upgradePerformed;
    address public ownerBeforeUpgrade;
    uint256 public totalSupplyBeforeUpgrade;
    mapping(address => uint256) public balanceBeforeUpgrade;

    /// @notice Interact with the protocol normally (pre-upgrade)
    function normalOperation(
        uint256 actorSeed,
        uint256 amount
    ) external {
        address user = _selectUser(actorSeed);
        amount = bound(amount, 1, 1e24);
        _performRandomAction(user, amount);
    }

    /// @notice Perform the upgrade
    function performUpgrade() external {
        if (upgradePerformed) return;

        // Snapshot all state before upgrade
        _snapshotState();

        // Upgrade
        vm.prank(admin);
        ProxyAdmin(proxyAdmin).upgradeAndCall(
            ITransparentUpgradeableProxy(address(proxy)),
            address(implV2),
            "" // No initialization call for this upgrade
        );

        upgradePerformed = true;
    }

    /// @notice Interact with the protocol after upgrade
    function postUpgradeOperation(
        uint256 actorSeed,
        uint256 amount
    ) external {
        if (!upgradePerformed) return;
        address user = _selectUser(actorSeed);
        amount = bound(amount, 1, 1e24);
        _performRandomAction(user, amount);
    }

    /// @notice Try to upgrade from unauthorized address
    function tryUnauthorizedUpgrade(uint256 actorSeed) external {
        address attacker = _selectUser(actorSeed);
        if (attacker == admin) return;

        upgradeAttemptCount++;
        UpgradeAttempt storage attempt = upgradeAttempts[
            upgradeAttemptCount - 1
        ];
        attempt.caller = attacker;

        vm.prank(attacker);
        try ProxyAdmin(proxyAdmin).upgradeAndCall(
            ITransparentUpgradeableProxy(address(proxy)),
            address(implV2),
            ""
        ) {
            attempt.succeeded = true; // This is bad
        } catch {
            attempt.succeeded = false; // Expected
        }
    }

    function _snapshotState() internal {
        ImplementationV1 proxyV1 = ImplementationV1(address(proxy));
        ownerBeforeUpgrade = proxyV1.owner();
        totalSupplyBeforeUpgrade = proxyV1.totalSupply();
        for (uint256 i = 0; i < trackedUsers.length; i++) {
            balanceBeforeUpgrade[trackedUsers[i]] = proxyV1.balanceOf(
                trackedUsers[i]
            );
        }
    }
}

The state snapshot is the handler's most important job. Without it, you can't verify that the upgrade preserved storage correctly.

Real upgrade bugs

Wormhole ($320M, February 2022)

The Wormhole bridge had a UUPS proxy whose implementation had an uninitialized guardian_set. The attacker called initialize() on the implementation contract directly, set themselves as the guardian, and approved fraudulent transfers. The initializer safety invariant catches this, the implementation contract itself must be initialized.

Audius ($6M, July 2022)

Audius used a custom proxy pattern where the governance contract could change the proxy's implementation. An attacker found an unguarded initialize function on a new implementation that let them take over the proxy and drain funds. The initialize-only-once property catches this directly.

Compound (October 2022, near-miss)

Compound's governance proposed a Comptroller upgrade that would have bricked the protocol. Community members caught it during the timelock delay. This isn't a fuzzing catch per se, but the storage layout preservation invariant would've flagged it during testing before the proposal was even created.

Beacon proxy properties

Beacon proxies add another layer. Multiple proxies share a single beacon that points to the implementation. Properties need to verify beacon consistency:

/// @notice All proxies using same beacon must use same implementation
function invariant_beacon_consistency() public view {
    address expectedImpl = beacon.implementation();
    for (uint256 i = 0; i < handler.proxyCount(); i++) {
        address proxyAddr = handler.proxies(i);
        bytes32 beaconSlot = bytes32(
            uint256(keccak256("eip1967.proxy.beacon")) - 1
        );
        bytes32 storedBeacon = vm.load(proxyAddr, beaconSlot);
        address proxyBeacon = address(
            uint160(uint256(storedBeacon))
        );

        // All proxies should point to our beacon
        assertEq(
            proxyBeacon,
            address(beacon),
            "Proxy points to wrong beacon"
        );
    }
}

/// @notice Beacon upgrade affects all proxies simultaneously
function invariant_beacon_upgrade_atomic() public view {
    if (!handler.beaconUpgradePerformed()) return;

    address newImpl = beacon.implementation();
    for (uint256 i = 0; i < handler.proxyCount(); i++) {
        // Each proxy should now use the new implementation
        // (verified by calling a function that only exists in V2)
        address proxyAddr = handler.proxies(i);
        try ImplementationV2(proxyAddr).newV2Function() {
            // Expected -- V2 function exists
        } catch {
            assertTrue(false, "Proxy not upgraded after beacon change");
        }
    }
}

Where to start

For proxy pattern testing, start here:

  1. Initializer safety, can't re-initialize. This is the #1 production exploit vector.
  2. Storage layout preservation, values survive upgrade intact.
  3. Upgrade authorization, only admin can upgrade.
  4. Implementation liveness, implementation is a valid contract.

Then add storage gap checks, beacon consistency (if applicable), and UUPS-specific properties.

If you're also testing the protocol that lives behind the proxy, combine these with protocol-specific invariants from invariant testing. The upgrade properties verify the container is safe; your protocol properties verify the contents are correct.

For more security testing patterns, check out smart contract security and why invariant testing matters for DeFi security.


Building with upgradeable proxies? Request an Audit or Try Recon Pro to generate upgrade-safety properties automatically.

Related Posts

Related Glossary Terms

Secure your upgrade path