Skip to content
Logo

Upgrading Contracts

This guide covers deploying and upgrading proxy contracts with Forge, including UUPS and Transparent proxies, storage layout verification, and safe upgrade practices.

Proxy patterns overview

PatternDescriptionGas cost
UUPSUpgrade logic in implementation, minimal proxyLower
TransparentUpgrade logic in proxy, admin separationHigher
BeaconMultiple proxies share upgrade logicEfficient for many instances

UUPS proxy deployment

In the UUPS pattern, the proxy is a thin contract that delegates all calls to an implementation contract. The implementation holds both the business logic and the upgrade logic. Because the proxy uses delegatecall, all state is stored on the proxy - the implementation is just code.

Create the implementation

Upgradeable contracts replace constructors with initialize functions, since constructor logic runs in the implementation's context, not the proxy's. _disableInitializers() in the constructor prevents anyone from calling initialize directly on the implementation, which could let an attacker take ownership of it.

src/TokenV1.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
 
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
 
contract TokenV1 is UUPSUpgradeable, OwnableUpgradeable {
    mapping(address => uint256) public balances;
    uint256 public totalSupply;
 
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers(); 
    }
 
    function initialize(address owner) external initializer { 
        __Ownable_init(owner);
        __UUPSUpgradeable_init();
    }
 
    function mint(address to, uint256 amount) external onlyOwner {
        balances[to] += amount;
        totalSupply += amount;
    }
 
    function _authorizeUpgrade(address) internal override onlyOwner {}
}

Deploy the proxy

Deployment is a two-step process: deploy the implementation, then deploy an ERC1967 proxy pointing to it. The proxy constructor accepts initialization calldata, so the initialize function is called atomically during deployment.

script/DeployToken.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
 
import {Script, console} from "forge-std/Script.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {TokenV1} from "../src/TokenV1.sol";
 
contract DeployToken is Script {
    function run() public {
        address owner = vm.envAddress("OWNER_ADDRESS");
 
        vm.startBroadcast();
 
        TokenV1 implementation = new TokenV1(); 
        
        bytes memory initData = abi.encodeCall(TokenV1.initialize, (owner));
        ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), initData); 
 
        vm.stopBroadcast();
 
        console.log("Implementation:", address(implementation));
        console.log("Proxy:", address(proxy));
    }
}

Upgrading a UUPS proxy

Create the new implementation

The new implementation inherits from V1 and adds state variables at the end of the storage layout. Inserting or reordering variables would corrupt existing proxy state.

src/TokenV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
 
import {TokenV1} from "./TokenV1.sol";
 
contract TokenV2 is TokenV1 {
    // New state variables must be added at the end
    mapping(address => bool) public frozen; 
 
    function freeze(address account) external onlyOwner {
        frozen[account] = true;
    }
 
    function version() external pure returns (uint256) {
        return 2;
    }
}

Deploy and upgrade

To upgrade, deploy the new implementation and call upgradeToAndCall on the proxy. This updates the implementation address stored in the proxy's ERC1967 slot. The second argument is optional calldata for a migration function - pass empty bytes if no re-initialization is needed.

script/UpgradeToken.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
 
import {Script, console} from "forge-std/Script.sol";
import {TokenV2} from "../src/TokenV2.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
 
contract UpgradeToken is Script {
    function run() public {
        address proxy = vm.envAddress("PROXY_ADDRESS");
 
        vm.startBroadcast();
 
        // Deploy new implementation
        TokenV2 newImplementation = new TokenV2();
 
        // Upgrade proxy to new implementation
        UUPSUpgradeable(proxy).upgradeToAndCall(
            address(newImplementation),
            "" // No initialization call needed for this upgrade
        );
 
        vm.stopBroadcast();
 
        console.log("New implementation:", address(newImplementation));
        console.log("Upgraded proxy:", proxy);
        console.log("Version:", TokenV2(proxy).version());
    }
}

Storage layout verification

Since the proxy stores all state, the new implementation's storage layout must be compatible with the old one. Foundry can export and diff storage layouts to catch breaking changes before deployment:

$ forge inspect src/TokenV1.sol:TokenV1 storage-layout --pretty > v1-layout.txt
$ forge inspect src/TokenV2.sol:TokenV2 storage-layout --pretty > v2-layout.txt
$ diff v1-layout.txt v2-layout.txt

Storage layout rules

RuleDescription
Never remove variablesDeleting state variables corrupts storage
Never reorder variablesChanging order shifts storage slots
Never change typesChanging a variable's type corrupts data
Add variables at the endNew variables go after existing ones
Use storage gapsReserve space for future variables

Using storage gaps

Storage gaps are fixed-size arrays that reserve empty storage slots in a base contract. When you add new state variables in an upgrade, you reduce the gap size by the same number of slots, keeping the total storage layout unchanged and preventing slot collisions with inherited contracts.

contract TokenV1 is UUPSUpgradeable, OwnableUpgradeable {
    mapping(address => uint256) public balances;
    uint256 public totalSupply;
    
    // Reserve 50 slots for future variables
    uint256[50] private __gap; 
}
 
contract TokenV2 is TokenV1 {
    // Uses one slot from the gap
    mapping(address => bool) public frozen; 
    
    // Update gap to maintain total slot count
    uint256[49] private __gap; 
}

Transparent proxy deployment

In the transparent proxy pattern, the proxy itself contains the upgrade logic and a separate admin address. Only the admin can call upgrade functions; all other callers are transparently delegated to the implementation. This avoids function selector clashes between the proxy and implementation but costs more gas per call due to the admin check.

script/DeployTransparent.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
 
import {Script, console} from "forge-std/Script.sol";
import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import {TokenV1} from "../src/TokenV1.sol";
 
contract DeployTransparent is Script {
    function run() public {
        address owner = vm.envAddress("OWNER_ADDRESS");
 
        vm.startBroadcast();
 
        // Deploy implementation
        TokenV1 implementation = new TokenV1();
        
        // Deploy proxy (ProxyAdmin is created automatically)
        bytes memory initData = abi.encodeCall(TokenV1.initialize, (owner));
        TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(
            address(implementation),
            owner, // Admin
            initData
        );
 
        vm.stopBroadcast();
 
        console.log("Implementation:", address(implementation));
        console.log("Proxy:", address(proxy));
    }
}

Testing upgrades

Upgrade tests should verify three things: state is preserved across the upgrade, new functionality works, and only authorized accounts can trigger upgrades.

test/TokenUpgrade.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
 
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {TokenV1} from "../src/TokenV1.sol";
import {TokenV2} from "../src/TokenV2.sol";
 
contract TokenUpgradeTest is Test {
    TokenV1 proxy;
    address owner = address(this);
 
    function setUp() public {
        TokenV1 implementation = new TokenV1();
        bytes memory initData = abi.encodeCall(TokenV1.initialize, (owner));
        proxy = TokenV1(address(new ERC1967Proxy(address(implementation), initData)));
    }
 
    function test_UpgradePreservesState() public {
        // Set state in V1
        proxy.mint(address(0x1), 1000); 
        assertEq(proxy.balances(address(0x1)), 1000);
 
        // Upgrade to V2
        TokenV2 newImpl = new TokenV2();
        proxy.upgradeToAndCall(address(newImpl), ""); 
 
        // State is preserved
        TokenV2 proxyV2 = TokenV2(address(proxy));
        assertEq(proxyV2.balances(address(0x1)), 1000); 
        assertEq(proxyV2.version(), 2);
    }
 
    function test_NewFunctionalityWorks() public {
        // Upgrade to V2
        TokenV2 newImpl = new TokenV2();
        proxy.upgradeToAndCall(address(newImpl), "");
 
        // Use new functionality
        TokenV2 proxyV2 = TokenV2(address(proxy));
        proxyV2.freeze(address(0x1));
        assertTrue(proxyV2.frozen(address(0x1)));
    }
 
    function test_RevertWhen_UnauthorizedUpgrade() public {
        TokenV2 newImpl = new TokenV2();
 
        vm.prank(address(0xdead));
        vm.expectRevert();
        proxy.upgradeToAndCall(address(newImpl), "");
    }
}

Safe upgrade checklist

Verify storage compatibility

$ forge inspect src/TokenV1.sol:TokenV1 storage-layout --pretty
$ forge inspect src/TokenV2.sol:TokenV2 storage-layout --pretty

Test the upgrade on a fork

$ forge test --match-contract TokenUpgradeTest --fork-url https://ethereum.reth.rs/rpc

Simulate the upgrade transaction

$ forge script script/UpgradeToken.s.sol --rpc-url https://ethereum.reth.rs/rpc

Broadcast with verification

$ forge script script/UpgradeToken.s.sol --broadcast --verify --rpc-url https://ethereum.reth.rs/rpc

Best practices

PracticeDescription
Use initializersNever use constructors for state in implementations
Disable initializersCall _disableInitializers() in implementation constructors
Storage gapsReserve slots for future variables
Test upgradesAlways test state preservation before upgrading
Timelock upgradesUse a timelock for production upgrade transactions
Verify implementationsAlways verify new implementations on block explorers