Alt text
 

Smart contracts on the blockchain are designed to be immutable, ensuring the integrity of on-chain agreements.
However, this immutability poses challenges when developers need to update contract logic, fix bugs, or implement security patches. Traditionally, such changes would require deploying an entirely new contract, resulting in a new address and potential disruption to existing integrations.

The proxy pattern has emerged as a popular solution to this challenge, enabling contract upgradeability while preserving the original address.

This pattern involves a two-contract system: a proxy contract and an implementation contract.

The proxy contract serves as the user-facing interface and data storage, while the implementation contract contains the actual logic.

When users interact with the proxy contract, this proxy contract uses the delegatecall() function to execute the logic from the implementation contract. This approach allows the proxy to modify its own state based on the implementation’s instructions. Upgrades are facilitated by updating a specific storage slot in the proxy contract, which points to the address of the current implementation contract.

Among the various proxy patterns available, two popular approaches are the Transparent Proxy and the Universal Upgradeable Proxy Standard (UUPS).
[another one named Diamond proxy has emerged lately but have not been studied as of the time of this writing] .
 

These 2 patterns offer different trade-offs and mechanisms for managing upgrades while maintaining contract functionality and security.
 

Transparent Proxy

This pattern includes the upgrade functionality within the proxy contract itself.

An administrator role is created with privilege to directly interact with the proxy contract to process to the upgrade of the referenced logic/implementation address.
Callers addresses that do not have this admin privilege will have their call delegated to the implementation contract :

1- Separation of concerns: The upgradeable functionality is kept within the proxy contract itself , separate from the implementation logic.

2- Admin roles : a designated administrator has the privilege to interact with the proxy contract for upgrades .

3- User interaction : regular users interact with the proxy contract, which then delegate calls to the implementation/logic contract .

4- Admin restrictions : the administrator can not directly interact with the implementation contract directly through the proxy.
      Alt text  

Below are two solidity files to illustrate the Transparent Proxy pattern:
one for the logic contract( let’s call it Implementation.sol & one for the proxy contract (let’s call it TransparentProxy.sol) .
 

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26; 

contract Implementation { 
    // state variables 
    uint256 public value; 
    address public owner ; 

    // Events 
    event ValueChanged(uint256 newValue); 

    // The constructor is replaced by an initializer function 
    // It can only be called once . 
    function initialize(address _owner) public { 
        require(owner == address(0), "already initialized"); 
        owner = _owner; 
    }

    // function to set a new value 
    // setValue allows the owner to change the value.
    function setValue(uint256 _newValue) public { 
        require(msg.sender == owner, "Not authorized ! "); 
        value = _newValue ; 
        emit ValueChanged(_newValue); 
    }

    //  getValue is a public function to  retrieve the current value 
    function getValue() public view returns (uint256) { 
        return value; 
    }
}

 

now the TransparentProxy.sol :
 

//SPDX-License-Identifier: MIT
pragma solidity 0.8.26; 

contract TransparentProxy { 
  // We use constant unique collision-resistant storage slots for the implementation & admin addresses 
  // to avoid storage collisions;

  // Storage slot with the address of the current implementation 
  bytes32 private constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256('eip1967.proxy.implementation')));
  // Storage slot with the address of the admin 
  bytes32 private constant ADMIN_SLOT = bytes32(uint256(keccak256('eip1967.proxy.admin'))); 

   event Upgraded(address indexed implementation); 
   event AdminChanged(address previousAdmin, address newAdmin);


  // The constructor sets the initial implementation and admin addresses . 
  constructor(address _implementation, address _admin) { 
    _setImplementation(_implementation) ; 
    _setAdmin(_admin); 
  }

  // The modifier checks if the caller is the admin. If not, it delegates the call to the implementation contract . 
  modifier ifAdmin () {
      if (msg.sender == _getAdmin()) { 
        _; 
      } else { 
        _fallback();
      }
  }

  // This 'upgradeTo' function allows the admin to upgrade the implementation.
  function upgradeTo(address _newImplementation) external ifAdmin { 
          _setImplementation(_newImplementation); 
          emit Upgraded(_newImplementation); 
  }



  // This 'changeAdmin' function allows changing the admin address . 
  function changeAdmin(address _newAdmin) external ifAdmin { 
      emit AdminChanged(_getAdmin(), _newAdmin); 
      _setAdmin(_newAdmin) ; 
  }


  //  the 'fallback'  function handle all calls to the proxy ,  delegating it to the implementation contract . 
  fallback() external { 
    _fallback(); 
  }



  // '_fallback' uses assembly to perform the actual  delegation to the implemetation contract using 
  // assembly for gas efficiency . 
    function _fallback() private {
        address _impl = _getImplementation();
        require(_impl != address(0), "Implementation not set");

        assembly {
            let ptr := mload(0x40)
            calldatacopy(ptr, 0, calldatasize())
            let result := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
            let size := returndatasize()
            returndatacopy(ptr, 0, size)

            switch result
            case 0 { revert(ptr, size) }
            default { return(ptr, size) }
        }
    }





  // '_setImplementation', 'setAdmin' , '_getImplementation' , '_getAdmin' use assembly to interact with specific storage slots . 
  // Theses internal functions handle setting and getting the implementation and admin address using the defined storage slots . 
function _setImplementation(address _newImpl) private {
        require(_newImpl != address(0), "Invalid implementation address");
        bytes32 slot = IMPLEMENTATION_SLOT;
        assembly {
            sstore(slot, _newImpl)
        }
    }

    function _setAdmin(address _newAdmin) private {
        require(_newAdmin != address(0), "Invalid admin address");
        bytes32 slot = ADMIN_SLOT;
        assembly {
            sstore(slot, _newAdmin)
        }
    }

    function _getImplementation() private view returns (address impl) {
        bytes32 slot = IMPLEMENTATION_SLOT;
        assembly {
            impl := sload(slot)
        }
    }

    function _getAdmin() private view returns (address adm) {
        bytes32 slot = ADMIN_SLOT;
        assembly {
            adm := sload(slot)
        }
    }


}

 

EIP-1967 (Ethereum Improvement Proposal 1967) is a standard for proxy storage slots.
It’s crucial for implementing upgradeable contracts securely.

Key Points of EIP-1967:

  1. Purpose:

    • Standardizes storage slots for proxy contracts.
    • Aims to prevent storage collisions between proxy and implementation contracts.
  2. Defined Storage Slots:

    • Implementation Address: Where the address of the logic contract is stored.
    • Admin Address: Where the address of the proxy admin is stored.
    • Beacon Address: Used in beacon proxy patterns.
  3. Slot Calculation:

    • Uses a specific formula to generate unique storage slots.
    • Example: bytes32(uint256(keccak256(’eip1967.proxy.implementation’)))
  4. Benefits:

    • Improves interoperability between different proxy implementations.
    • Reduces the risk of storage collisions.
    • Enables easier verification and interaction with proxy contracts.
  5. Usage:

    • Widely adopted in popular libraries like OpenZeppelin.
    • Used in our TransparentProxy example for storing implementation and admin addresses.
  6. Security:

    • By using these standardized slots, it’s much harder for upgrades to accidentally overwrite important proxy data.
  7. Transparency:

    • Makes it easier for external tools and block explorers to recognize and interact with proxy contracts.
       

In practice, when implementing a proxy contract following EIP-1967, you use these standardized storage slots to store critical proxy-related data. This approach significantly enhances the security and reliability of upgradeable contract systems. The standard is a crucial part of best practices in developing upgradeable smart contracts in the Ethereum ecosystem.
 

Step by step process for deployment of Upgradeable smart contracts

When deploying a project using Transparent Proxy pattern, we would typically follow a specific sequence.

1- Deploy the Implementation contract first - as mentioned earlier, this contract contains the actual logic but is not interacting with by the regular users of your dApp.

2- Deploy the Proxy contract second. When deploying the proxy , you’ll need to provide two parameters:
a. The address of the implementation contract you just deployed.
b. The address that will serve as the admin (often a multi-sig wallet like the Safe app).

3- Once both contracts are deployed, the admin address need to call the initialize function on the proxy contract (this can be done via the UI of your block explorer or via a Foundry cast call). This final step is crucial as it set up the initialize the state of your contract .  

All these deployment steps can be done via a solidity scripting file (DeployScript.sol) in Foundry as such :

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

import {Script, console} from "../lib/forge-std/src/Script.sol";
import { Implementation } from "../src/Implementation.sol";
import { TransparentProxy } from "../src/TransparentProxy.sol"; 
import { Vm } from "../lib/forge-std/src/Vm.sol"; 

contract DeployScript is Script {
    function run() external {
      // the private key of the admin/deployer is retrieved thru the env variable.
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        address deployerAddress = vm.addr(deployerPrivateKey);

        // the deployment is wrapped in 'vm.startBroadcast' method 
        // which is foundry way of simulating sending tx from the specified private key. 
        vm.startBroadcast(deployerPrivateKey);

        // Deployment of the  Implementation contract firstly . 
        Implementation implementation = new Implementation();
        console.log("Implementation deployed at:", address(implementation));

        // Deployment of the TransparentProxy contract secondly . 
        // the address of the first deployed contract is provided as first param & 
        // the admin address is provided as second param. 
        TransparentProxy proxy = new TransparentProxy(address(implementation), deployerAddress);
        console.log("TransparentProxy deployed at:", address(proxy));
    }
}

 

Then, if you have all your credentials and api keys in an environment variable file like a .envrc , after compilation, we process to deployment :
‘forge script –rpc-url $RPC_URL –private-key $PRIVATE_KEY
–broadcast –verify script/DeployScript.s.sol/DeployScript –etherscan-api-key $ETHERSCAN_API_KEY –chain-id –vvv’
 

Nevertheless, most of the solidity coders often choose to use OpenZeppelin libraires such as contracts-upgradeable to abstract away most of the Assembly coding for storage slots , for better security and code readability.