🎓 A1Learner
🔍
0 of 45 lessons completed

Solidity UUPS in Depth

UUPS (Universal Upgradeable Proxy Standard) is an upgrade pattern where the upgrade function lives in the logic contract, not in the proxy. The proxy forwards every call to the logic contract, so the upgrade call reaches the logic code.

An upgrade through the proxy keeps the saved value

The owner upgrades through the proxy. The saved value survives, and the new function appears.

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

contract VaultV1 {
    uint256 public saved;
    address public owner;
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    function initialize(address admin) public {
        require(owner == address(0), "already initialized");
        owner = admin;
    }
    function addSaved(uint256 v) public {
        saved += v;
    }

    function proxiableUUID() public pure returns (bytes32) {
        return IMPL_SLOT;
    }
    function upgradeTo(address newImpl) public {
        require(msg.sender == owner, "only owner");
        (bool ok, bytes memory ret) = newImpl.staticcall(abi.encodeWithSignature("proxiableUUID()"));
        require(ok && ret.length == 32 && abi.decode(ret, (bytes32)) == IMPL_SLOT, "not a UUPS contract");
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, newImpl) }
    }
}

contract VaultV2 {
    uint256 public saved;
    address public owner;
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    function initialize(address admin) public {
        require(owner == address(0), "already initialized");
        owner = admin;
    }
    function addSaved(uint256 v) public {
        saved += v;
    }
    function doubleSaved() public {
        saved *= 2;
    }

    function proxiableUUID() public pure returns (bytes32) {
        return IMPL_SLOT;
    }
    function upgradeTo(address newImpl) public {
        require(msg.sender == owner, "only owner");
        (bool ok, bytes memory ret) = newImpl.staticcall(abi.encodeWithSignature("proxiableUUID()"));
        require(ok && ret.length == 32 && abi.decode(ret, (bytes32)) == IMPL_SLOT, "not a UUPS contract");
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, newImpl) }
    }
}

contract UupsProxy {
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    constructor(address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, impl) }
    }
    function implementation() public view returns (address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { impl := sload(slot) }
    }
    fallback() external payable {
        address impl = implementation();
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

Only the owner can upgrade a UUPS proxy

The upgrade function checks the caller against the stored owner. A stranger call reverts.

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

contract VaultV1 {
    uint256 public saved;
    address public owner;
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    function initialize(address admin) public {
        require(owner == address(0), "already initialized");
        owner = admin;
    }
    function addSaved(uint256 v) public {
        saved += v;
    }

    function proxiableUUID() public pure returns (bytes32) {
        return IMPL_SLOT;
    }
    function upgradeTo(address newImpl) public {
        require(msg.sender == owner, "only owner");
        (bool ok, bytes memory ret) = newImpl.staticcall(abi.encodeWithSignature("proxiableUUID()"));
        require(ok && ret.length == 32 && abi.decode(ret, (bytes32)) == IMPL_SLOT, "not a UUPS contract");
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, newImpl) }
    }
}

contract VaultV2 {
    uint256 public saved;
    address public owner;
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    function initialize(address admin) public {
        require(owner == address(0), "already initialized");
        owner = admin;
    }
    function addSaved(uint256 v) public {
        saved += v;
    }
    function doubleSaved() public {
        saved *= 2;
    }

    function proxiableUUID() public pure returns (bytes32) {
        return IMPL_SLOT;
    }
    function upgradeTo(address newImpl) public {
        require(msg.sender == owner, "only owner");
        (bool ok, bytes memory ret) = newImpl.staticcall(abi.encodeWithSignature("proxiableUUID()"));
        require(ok && ret.length == 32 && abi.decode(ret, (bytes32)) == IMPL_SLOT, "not a UUPS contract");
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, newImpl) }
    }
}

contract UupsProxy {
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    constructor(address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, impl) }
    }
    function implementation() public view returns (address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { impl := sload(slot) }
    }
    fallback() external payable {
        address impl = implementation();
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

An upgrade to a non-UUPS contract reverts

The upgrade function checks the target for the UUPS marker first. A plain contract fails that check, so the proxy keeps working.

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

contract VaultV1 {
    uint256 public saved;
    address public owner;
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    function initialize(address admin) public {
        require(owner == address(0), "already initialized");
        owner = admin;
    }
    function addSaved(uint256 v) public {
        saved += v;
    }

    function proxiableUUID() public pure returns (bytes32) {
        return IMPL_SLOT;
    }
    function upgradeTo(address newImpl) public {
        require(msg.sender == owner, "only owner");
        (bool ok, bytes memory ret) = newImpl.staticcall(abi.encodeWithSignature("proxiableUUID()"));
        require(ok && ret.length == 32 && abi.decode(ret, (bytes32)) == IMPL_SLOT, "not a UUPS contract");
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, newImpl) }
    }
}

contract VaultV2 {
    uint256 public saved;
    address public owner;
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    function initialize(address admin) public {
        require(owner == address(0), "already initialized");
        owner = admin;
    }
    function addSaved(uint256 v) public {
        saved += v;
    }
    function doubleSaved() public {
        saved *= 2;
    }

    function proxiableUUID() public pure returns (bytes32) {
        return IMPL_SLOT;
    }
    function upgradeTo(address newImpl) public {
        require(msg.sender == owner, "only owner");
        (bool ok, bytes memory ret) = newImpl.staticcall(abi.encodeWithSignature("proxiableUUID()"));
        require(ok && ret.length == 32 && abi.decode(ret, (bytes32)) == IMPL_SLOT, "not a UUPS contract");
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, newImpl) }
    }
}

contract NotUups {
    uint256 public saved;
    function addSaved(uint256 v) public {
        saved += v;
    }
}

contract UupsProxy {
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    constructor(address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, impl) }
    }
    function implementation() public view returns (address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { impl := sload(slot) }
    }
    fallback() external payable {
        address impl = implementation();
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

Example

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

contract VaultV1 {
    uint256 public saved;
    address public owner;
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    function initialize(address admin) public {
        require(owner == address(0), "already initialized");
        owner = admin;
    }
    function addSaved(uint256 v) public {
        saved += v;
    }

    function proxiableUUID() public pure returns (bytes32) {
        return IMPL_SLOT;
    }
    function upgradeTo(address newImpl) public {
        require(msg.sender == owner, "only owner");
        (bool ok, bytes memory ret) = newImpl.staticcall(abi.encodeWithSignature("proxiableUUID()"));
        require(ok && ret.length == 32 && abi.decode(ret, (bytes32)) == IMPL_SLOT, "not a UUPS contract");
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, newImpl) }
    }
}

contract VaultV2 {
    uint256 public saved;
    address public owner;
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    uint256 public bonus;

    function initialize(address admin) public {
        require(owner == address(0), "already initialized");
        owner = admin;
    }
    function addSaved(uint256 v) public {
        saved += v;
    }
    function setBonus(uint256 v) public {
        bonus = v;
    }
    function total() public view returns (uint256) {
        return saved + bonus;
    }

    function proxiableUUID() public pure returns (bytes32) {
        return IMPL_SLOT;
    }
    function upgradeTo(address newImpl) public {
        require(msg.sender == owner, "only owner");
        (bool ok, bytes memory ret) = newImpl.staticcall(abi.encodeWithSignature("proxiableUUID()"));
        require(ok && ret.length == 32 && abi.decode(ret, (bytes32)) == IMPL_SLOT, "not a UUPS contract");
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, newImpl) }
    }
}

contract UupsProxy {
    bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    constructor(address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { sstore(slot, impl) }
    }
    function implementation() public view returns (address impl) {
        bytes32 slot = IMPL_SLOT;
        assembly { impl := sload(slot) }
    }
    fallback() external payable {
        address impl = implementation();
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

Key points

  • UUPS upgrade: the upgrade function lives in the logic contract and runs through the proxy.
  • Proxy forwarding: the proxy forwards every call to the logic contract, including the upgrade call.
  • Implementation slot: the proxy stores the logic address in the ERC-1967 implementation slot, a fixed high slot that never collides with logic values.
  • Owner guard: only the owner may call the upgrade function.
  • Marker guard: an upgrade target must return the UUPS marker, or the upgrade reverts before anything changes.

In this example, VaultV1 holds a saved amount and an owner, plus the upgrade function and the UUPS marker. VaultV2 keeps the same layout and adds a bonus and a total. The proxy starts on VaultV1, the owner sets 40, then upgrades through the proxy, and the upgraded vault reports 40 saved and a total of 45 once the bonus is 5.

Note

a logic contract called directly never changes the proxy, because only calls through the proxy write proxy storage.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Remix IDE

Exercise

Where does the upgrade function live in a UUPS proxy system?