Post

Proxy Contracts - ERC1967Proxy, Transparent and UUPS explained.

In-depth explanation.

PS: This is more like a detailed explanation of https://docs.openzeppelin.com/contracts/5.x/api/proxy with multiple examples and implementations.

Also posted at https://ethereum.stackexchange.com/questions/114809/what-exactly-is-a-proxy-contract-and-why-is-there-a-security-vulnerability-invol/163550#163550

  • This is our example contract we’re going to be using for all the test cases.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
contract Bank {

    address owner;
    mapping(address => uint) balance;
   
    constructor() {
        owner = msg.sender;
    }

    function deposit() public payable {
        balance[msg.sender] = msg.value;
    }

    function changeOwner(address _addr) public {
        require(msg.sender == owner);
        owner = _addr;
    }

    function withdraw() public payable {
        msg.sender.call{value: balance[msg.sender]}("");
    }

    function getOwner() public view returns(address) {
        return owner;
    }
    
}
  • Say the bank contract is deployed and a lot of users are using it right now but imagine there come’s a scenario where we found a critical vulnerability in the contract or suppose we want to upgrade our contract and add some functionalities; What are we gonna do about it?

Nothing really, smart contracts are immutable by design; meaning after we deploy it there’s pretty much nothing we can do.

So there are multiple workarounds for our above scenario.

From: https://ethereum.org/en/developers/docs/smart-contracts/upgrading/

We can see that “Upgrade mechanism #1: Contract migration” is the best we could do rn. Aka creating a new fresh upgraded contract and migrating the entire storage which is obviously going to be a huge and tedious task if the contract has a high user interaction.


To overcome these issues we can use something named a proxy.

From https://docs.openzeppelin.com/contracts/5.x/api/proxy , we can see that there are Proxy, ERC1967Proxy and it’s two upgradeability mechanisms transparent and UUPS.

Let’s start from Proxy.

Proxy: Abstract contract implementing the core delegation functionality.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.0.0) (proxy/Proxy.sol)

pragma solidity ^0.8.20;

abstract contract Proxy {

    function _delegate(address implementation) internal virtual {
        assembly {

            calldatacopy(0, 0, calldatasize())

            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

            returndatacopy(0, 0, returndatasize())

            switch result

            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }


    function _implementation() internal view virtual returns (address);


    function _fallback() internal virtual {
        _delegate(_implementation());
    }


    fallback() external payable virtual {
        _fallback();
    }
}

As we can see that this contract has a fallback function which delegates our call to the logic contract (The bank contract), so all the state variables is going to be stored in this contract and we’re going to just use the logic contract only for the code.

Below is the implementation of this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pragma solidity 0.8;

import "../helpers/Proxy.sol";

contract MyProxy is Proxy {

    address current;

    constructor(address _addr) {
        current = _addr;
    }

    function _implementation() internal view override returns (address) {
        return current;
    }

    receive() external payable { }

}
  • The delegatecall function runs the code of the logic contract as it’s own code meaning msg.sender and msg.value is going to be the same.

  • So we set our logic contract as our bank contract and give the users this proxy to interact with it. We set the abi of this proxy contract as the one of bank contract.

  • Say if we call deposit() function to the proxy contract. Since there’s no function named deposit() in the proxy contract, it directly goes to the fallback function of the proxy contract and from there delegatecall is executed with calldata as deposit(), The proxy contract uses the Bank contract just for the logic but the storage is updated in the proxy contract itself.

But guess what, the above code is actually really really insecure, can you guess why? Yes! Storage collision.

The address current and address owner points the same slot which is slot0.

If we call the changeOwner(address) function the current implementation is going to be replaced by this new address which is completely going to break our contract. So to prevent this something needs to be done.

Can you think of a way? Yes! Store this implementation address in a really random slot which is never going to be overwritten.


EIP1967 Standard

PS: We are going to update the contructor of our logic contract to an init function. You’ll see why later.

In order to avoid clashes with the storage variables of the implementation contract behind a proxy, we use EIP1967 storage slots.

The slot of the implementation is stored at:

bytes32 internal constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

Which is “The keccak-256 hash of “eip1967.proxy.implementation” subtracted by 1.”

Implementation of the contract:

1
2
3
4
5
6
7
8
9
10
11
12
pragma solidity 0.8;

import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

contract MyProxy is ERC1967Proxy {

    constructor(address implementation, bytes memory _data) 
            ERC1967Proxy(implementation, _data) {

    }

}

By default the above contract is not upgradeable. Here’s how it works.

  • First the implementation address and the init data is passed to the constructor of the ERC1967 proxy function.
1
2
3
constructor(address implementation, bytes memory _data) payable {
        ERC1967Utils.upgradeToAndCall(implementation, _data);
    }
  • Then the value in slot is updated with the new implementation address.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function _setImplementation(address newImplementation) private {
        if (newImplementation.code.length == 0) {
            revert ERC1967InvalidImplementation(newImplementation);
        }
        StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value = newImplementation;
    }

    function upgradeToAndCall(address newImplementation, bytes memory data) internal {
        _setImplementation(newImplementation);
        emit Upgraded(newImplementation);

        if (data.length > 0) {
            Address.functionDelegateCall(newImplementation, data);
        } else {
            _checkNonPayable();
        }
    }
  • Finally the init function is called.
1
2
3
4
function functionDelegateCall(address target, bytes memory data) internal returns (bytes memory) {
        (bool success, bytes memory returndata) = target.delegatecall(data);
        return verifyCallResultFromTarget(target, success, returndata);
    }

From openzeppelin docs:

This is a base contract to aid in writing upgradeable contracts, or any kind of contract that will be deployed behind a proxy. Since proxied contracts do not make use of a constructor, it’s common to move constructor logic to an external initializer function, usually called initialize.

Now let’s update our Bank contract.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
contract Bank {

    address owner;
    mapping(address => uint) balance;
    bool private initialized;

    function init(address _owner) public {
        require(!initialized, "Contract instance has already been initialized");
        initialized = true;
        owner = _owner;
    }

    function deposit() public payable {
        balance[msg.sender] = msg.value;
    }

    function withdraw() public payable {
        msg.sender.call{value: balance[msg.sender]}("");
    }

    function changeOwner() public {
        owner = msg.sender;
    }

    function getOwner() public view returns(address) {
        return owner;
    }
    
}

As we can see that our contract now has an init function which is initialized when we first set the implementation to our proxy.

Let’s deploy our bank contract and initialize it with proxy contract.

ERC1967Proxy(0x1d72840e4D2E0533c41386986D4Ed3405c171783, 0x19ab453c0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4)

Use https://abi.hashex.org/ to encode according to abi specification.

The most important thing to note here is that the init function updates the slot0(owner variable) of the proxy contract not the actualy deployed contract because the proxy contract delegatecalls to the init function not a usual call. No state variables is updated in the actualy implementation of the bank contract , it’s sole purpose is for logic.

If we check the slot0 of the proxy contract it points the owner variable.


Just like said above, by default the ERC1967 standard is not upgradeable.

From openzeppelin docs:

There are two alternative ways to add upgradeability to an ERC1967 proxy

TransparentUpgradeableProxy and UUPSUpgradeable


TransparentUpgradeableProxy

One another issue we face in the above standard is that proxy selector clashing can occur; meaning imagine if we had a function with same name in our implementation contract and our proxy contract, what is going to happen then? The one in the proxy contract is going to be called and the one in implementation is ignored.

A major use case of transparentupgradeable proxy is to prevent this.

If any account other than the admin calls the proxy, the call will be forwarded to the implementation.

If the admin calls the proxy, it can call the upgradeToAndCall function but any other call won’t be forwarded to the implementation. If the admin tries to call a function on the implementation it will fail with an error indicating the proxy admin cannot fallback to the target implementation.

Implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pragma solidity 0.8;

import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";

contract ProxyChan is TransparentUpgradeableProxy {

    constructor(address implementation, bytes memory _data) 
        TransparentUpgradeableProxy(implementation, msg.sender, _data) {
    }

    function getOwn() public  returns(address) {
        return super._proxyAdmin();
    }

}

constructor(address _logic, address initialOwner, bytes _data)

The most important thing here to note is that a new contract named Proxy admin is going to be deployed and via this contract the initial owner can upgrade the logic address.

The getOwn() function returns the address of the ProxyAdmin smart contract which has it’s owner set as the initalowner.

If we try to upgrade using any other account it returns “OwnableUnauthorizedAccount”.

These properties mean that the admin account can only be used for upgrading the proxy, so it’s best if it’s a dedicated account that is not used for anything else. This will avoid headaches due to sudden errors when trying to call a function from the proxy implementation. For this reason, the proxy deploys an instance of ProxyAdmin and allows upgrades only if they come through it. You should think of the ProxyAdmin instance as the administrative interface of the proxy, including the ability to change who can trigger upgrades by transferring ownership.


UUPS proxy

In the case of uups proxy the upgrade mechanism is included in the implementation contract itself but not in the proxy contract. Check out the comparison to find out why.

ERC1967 proxy:

1
2
3
4
5
6
7
8
9
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

contract MyProxy is ERC1967Proxy {

    constructor(address implementation, bytes memory _data) 
            ERC1967Proxy(implementation, _data) {
    }

}

Bank contract(Our implementation contract) V1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";

contract BankV1 is UUPSUpgradeable {

    address owner;

    mapping(address => uint) balance;
    bool private initialized;

    function _authorizeUpgrade(address implementation) internal override view  {
        require(msg.sender == owner, "Can't upgrade");
    }

    function init(address _owner) public {
        require(!initialized, "Contract instance has already been initialized");
        initialized = true;
        owner = _owner;
    }

    function _disableInit() internal override  {
        initialized = false;
    }

    function deposit() public payable {
        balance[msg.sender] = msg.value;
    }

    function withdraw() public payable {
        msg.sender.call{value: balance[msg.sender]}("");
    }

    function changeOwner() public {
        owner = msg.sender;
    }

    function getOwner() public view returns(address) {
        return owner;
    }
    
}

The initilized variable is temporarily disalbed when upgrading to the new contract, Make sure to use Initializable.sol from openzeppelin to do it in a much more safe and efficient way.

1
2
3
function _disableInit() internal override  {
        initialized = false;
    }
1
function _disableInit() internal virtual;

Add this line to UUPSUpgradeable.sol and modify the upgradeToAndCall() as:

1
2
3
4
5
6
function upgradeToAndCall(address newImplementation, bytes memory data) public payable virtual onlyProxy {
        _disableInit();
        _authorizeUpgrade(newImplementation);
        _upgradeToAndCallUUPS(newImplementation, data);
    }

DOING THIS IS REALLY INSECURE IN A SORT OF WAY, JUST FOR DEOMSTRATION PURPOSE/TO SHOW YOU GUYS THIS IS DONE , MAKE SURE TO USE INITIALIZABLE.SOL

Now our BankV1 has a function named upgradeToAndCall()

Let’s upgrade our contract.

BankV2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";

contract BankV2 is UUPSUpgradeable {

    address owner;

    mapping(address => uint) balance;
    bool private initialized;

    function _authorizeUpgrade(address implementation) internal override view  {
        require(msg.sender == owner, "Can't upgrade");
    }

    function init(address _owner) public {
        require(!initialized, "Contract instance has already been initialized");
        initialized = true;
        owner = _owner;
    }

    function bankName() public pure returns(string memory) {
        return "RandomName";
    }

    function _disableInit() internal override  {
        initialized = false;
    }

    function deposit() public payable {
        balance[msg.sender] = msg.value;
    }

    function withdraw() public payable {
        msg.sender.call{value: balance[msg.sender]}("");
    }

    function changeOwner() public {
        owner = msg.sender;
    }

    function getOwner() public view returns(address) {
        return owner;
    }
    
}

Call upgradeToAndCall() to the proxy contract with the new implementation(Bank V2) and data, we can see that our logic contract has been upgraded and if we can call bankName() to our proxy contract it returns the name.


If you have a strong solid understanding of delegatecall() function then implementing proxies is not that of a big deal.

This post is licensed under CC BY 4.0 by the author.