ChainScore Labs
LABS
Guides

Understanding Delegatecall and Proxy Patterns

Chainscore © 2025
core-concepts

Core Concepts

Foundational patterns for upgradeable and gas-efficient smart contract systems.

01

Delegatecall Opcode

The delegatecall EVM opcode executes code from a target contract within the context of the calling contract. This means the logic runs using the caller's storage, msg.sender, and msg.value.

  • Preserves the calling contract's storage state
  • Enables logic and storage separation
  • Critical for creating proxy patterns and modular systems
  • Used by libraries like OpenZeppelin's Proxy for upgrades
02

Proxy Pattern

A proxy pattern uses a proxy contract that delegates all calls to a separate logic contract via delegatecall. Users interact with the proxy, which holds the storage, while the logic is upgradeable.

  • Proxy holds the persistent state
  • Logic contract contains executable code
  • Enables seamless contract upgrades without migration
  • Fundamental for protocols like Uniswap and Aave v2
03

Storage Collisions

Storage collisions occur when a proxy and its logic contract define variables in conflicting storage slots, leading to critical state corruption. This is a major security risk in upgradeable contracts.

  • Caused by incompatible storage layouts between versions
  • Can be mitigated using unstructured storage patterns
  • Tools like OpenZeppelin Upgrades Plugin help prevent them
  • Requires careful inheritance and slot management
04

Transparent Proxy

A Transparent Proxy pattern restricts who can call the admin functions versus the regular logic. It prevents a malicious admin from impersonating a user and exploiting the proxy.

  • Uses a ProxyAdmin contract to manage upgrades
  • msg.sender determines if call is delegated or handled by proxy
  • Mitigates the "admin hijack" vulnerability
  • The default pattern in OpenZeppelin's upgrade system
05

UUPS Proxies

UUPS (Universal Upgradeable Proxy Standard) proxies move the upgrade logic into the implementation contract itself, rather than the proxy. This makes proxies cheaper to deploy.

  • Implementation contract contains the upgradeTo function
  • Reduces proxy deployment gas costs significantly
  • Requires the implementation to remain upgradeable
  • Introduced in EIP-1822 and used widely in newer protocols
06

Initialization

Initialization replaces constructors in upgradeable contracts, as a constructor's code is part of the creation bytecode and not stored for delegatecall. An initializer function sets up the initial state.

  • Uses initializer modifier to prevent reinitialization
  • Often part of an initialize function
  • Crucial for setting owners, thresholds, and initial values
  • Managed by libraries like OpenZeppelin's Initializable

How Delegatecall Works

Process overview

1

Initiate the Call

A contract calls the delegatecall opcode with target address and calldata.

Detailed Instructions

The process begins when a calling contract executes the delegatecall opcode. This opcode requires two primary arguments: the address of the implementation contract (the target) and the calldata to be sent. The calldata includes the function selector and any encoded arguments for the function you intend to execute on the target. Crucially, msg.sender and msg.value are preserved from the original call to the proxy, maintaining the transaction's original context.

  • Sub-step 1: Construct the calldata using abi.encodeWithSelector for the desired function.
  • Sub-step 2: Execute delegatecall via a low-level call: (bool success, ) = implementation.delegatecall(data);
  • Sub-step 3: Check the success boolean to verify the call did not revert.
solidity
// Example of initiating a delegatecall (bool success, bytes memory returnData) = _implementation.delegatecall( abi.encodeWithSignature("updateValue(uint256)", 42) ); require(success, "Delegatecall failed");

Tip: The calling contract's storage, balance, and address (this) are used during execution, not the target's.

2

Load and Execute Target Code

The EVM loads the target contract's code but executes it within the caller's context.

Detailed Instructions

Upon execution, the Ethereum Virtual Machine (EVM) loads the runtime bytecode from the storage of the implementation contract's address. However, it does not create a new execution context. Instead, it runs this loaded code within the existing context of the calling contract. This means all state-modifying operations (SSTORE) and state-reading operations (SLOAD) reference the storage slots of the calling contract. The ADDRESS opcode will return the caller's address, and BALANCE will return the caller's ether balance.

  • Sub-step 1: The EVM jumps to the target address in state and fetches its code.
  • Sub-step 2: Execution begins using the caller's storage, msg.sender, msg.value, and address.
  • Sub-step 3: Opcodes like EXTCODESIZE on the target address will still return the implementation's code size.
solidity
// Code from the implementation contract that will run in the caller's context function updateValue(uint256 newValue) public { // This SLOAD/SSTORE accesses the storage of the calling proxy storedValue = newValue; }

Tip: Because execution context is preserved, any selfdestruct in the target code would destroy the calling contract, not the implementation.

3

Modify Caller's Storage

The executed function reads from and writes to the storage layout of the calling contract.

Detailed Instructions

This is the core behavioral difference from a standard call. When the implementation's function logic executes, any interaction with contract storage directly manipulates the storage of the proxy contract. This requires strict storage layout compatibility between the proxy and implementation. If the implementation writes to its first defined state variable at slot 0, it will overwrite whatever data the proxy has stored at slot 0. This is why upgradeable proxies use unstructured storage patterns or specific storage slots to avoid collisions.

  • Sub-step 1: The implementation's SSTORE opcode uses the caller's storage address.
  • Sub-step 2: Verify the proxy's storage layout matches the implementation's expected layout to prevent catastrophic data corruption.
  • Sub-step 3: After execution, inspect the proxy's storage (e.g., via eth_getStorageAt) to confirm the update.
solidity
// Incompatible storage layouts are a critical risk. // Proxy storage: address _owner; uint256 _value; // Implementation logic expecting: uint256 _value; address _owner; // A delegatecall would assign values to the wrong slots.

Tip: Use the @openzeppelin/upgrades plugin to automatically validate storage layout compatibility during upgrades.

4

Return Data and Handle Outcome

Execution results are returned to the original calling contract's context.

Detailed Instructions

After the external code finishes execution, control returns to the point immediately after the delegatecall opcode in the calling contract. The return data from the executed function is passed back. The calling contract must handle this data, which typically involves decoding it if the function returns values. It must also check the success status of the low-level call. A failed call (e.g., due to a revert in the implementation) will return success = false, and any state changes made during the call are reverted, but gas up to that point is consumed.

  • Sub-step 1: Capture the bytes memory returnData from the delegatecall.
  • Sub-step 2: If successful, decode the return data using abi.decode based on the expected function signature.
  • Sub-step 3: If unsuccessful, handle the revert appropriately; consider emitting an event or implementing a fallback mechanism.
solidity
// Handling return data from a delegatecall (bool success, bytes memory data) = impl.delegatecall( abi.encodeWithSignature("getValue()") ); require(success, "Call failed"); uint256 result = abi.decode(data, (uint256));

Tip: The gas stipend for sub-calls (like transfer) within a delegatecall is based on the caller's context, which can affect gas calculations.

5

Understand Context Preservation

Key contextual variables remain unchanged from the original transaction.

Detailed Instructions

A critical feature of delegatecall is the preservation of the original execution context. This has significant security and design implications. The variables msg.sender, msg.value, and tx.origin refer to the original external account or contract that initiated the transaction chain to the proxy. The block variables (like block.number) and gasleft() are also from the current execution frame. This context preservation is what allows proxy contracts to act as non-custodial forwarding agents, enabling users to interact directly with the logic contract while the proxy holds the state.

  • Sub-step 1: Analyze access control: An onlyOwner modifier in the implementation will check msg.sender against the proxy's owner storage slot.
  • Sub-step 2: Consider value handling: Since msg.value is preserved, the proxy must be payable to accept ether sent with the transaction.
  • Sub-step 3: Audit for context assumptions: Ensure logic doesn't incorrectly rely on address(this), which is the proxy's address.
solidity
// Example of context-dependent logic in an implementation function deposit() public payable { // `msg.sender` is the original user, `address(this)` is the proxy address. balances[msg.sender] += msg.value; }

Tip: This context preservation is why a delegatecall to a malicious contract can be extremely dangerous, as it gains full write access to the caller's storage.

Proxy Pattern Implementations

Understanding the Core Idea

A proxy pattern is a design that separates a contract's storage and logic. Think of it like a library card catalog (the proxy) that points to different books (the logic contracts). The proxy holds all the user data, while the logic contracts contain the rules. This allows the rules to be updated without moving the data.

Key Points

  • Upgradability: The main benefit. Developers can fix bugs or add features by pointing the proxy to a new logic contract, like upgrading an app on your phone.
  • Gas Efficiency: Creating a new user contract is expensive. Proxies let many users share a single, updatable logic contract, saving gas.
  • Transparent Proxies: A common type where all function calls go through the proxy. It decides whether to delegate the call to the logic or run its own admin functions.

Real-World Example

When you interact with a protocol like Aave, you are likely calling a proxy contract. Your deposited funds are stored in the proxy's storage. When Aave's developers release a new version, they deploy a new logic contract and update the proxy's pointer, upgrading the system for all users without touching their deposits.

Proxy Pattern Comparison

Comparison of common proxy pattern implementations for upgradeable smart contracts.

FeatureTransparent Proxy (EIP-1967)UUPS (EIP-1822)Beacon Proxy

Upgrade Logic Location

Proxy Contract

Implementation Contract

Beacon Contract

Proxy Deployment Gas Cost

~1,200,000 gas

~900,000 gas

~1,500,000 gas

Upgrade Call Gas Overhead

~45,000 gas

~25,000 gas

~30,000 gas

Implementation Storage Slot

0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50

Admin/Upgrader Storage

Separate admin address slot

Integrated into implementation

Beacon contract owner

Implementation Size Limit

No inherent limit

Must fit 24KB contract size limit

No inherent limit

Security Consideration

Admin confusion attack risk

Implementation self-destruct risk

Single point of failure (beacon)

Common Usage

OpenZeppelin, older systems

Minimal proxies, newer standards

Mass-upgrade scenarios

Common Security Vulnerabilities

Process overview for identifying and mitigating critical risks in delegatecall and proxy implementations.

1

Identify Storage Collision Risks

Analyze how storage layouts between proxy and implementation can conflict.

Detailed Instructions

Storage collision occurs when the proxy and implementation contracts define variables in the same storage slots, leading to data corruption. This is a fundamental risk in upgradeable proxy patterns.

  • Sub-step 1: Map the storage layout of the proxy contract, noting all state variable positions starting from slot 0. Common proxy variables include _implementation and _admin.
  • Sub-step 2: Compare with the implementation's layout. The first variable in the implementation must not occupy slot 0, as this is typically reserved for the proxy's logic address. Use solc --storage-layout to generate a layout report.
  • Sub-step 3: Verify inheritance ordering. If the implementation inherits from other contracts, ensure the combined storage layout from parent contracts does not shift variables into conflicting slots.
solidity
// Problematic storage layout example contract Proxy { address public implementation; // Stored at slot 0 } contract Implementation { address public owner; // Also attempts to use slot 0 -> COLLISION }

Tip: Use unstructured storage proxies (like EIP-1967) which store the implementation address at a specific, randomized slot (e.g., 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc) to avoid this collision.

2

Audit Delegatecall Context Preservation

Examine how msg.sender and msg.value behave during delegatecall execution.

Detailed Instructions

Context preservation means msg.sender and msg.value in the delegatecalled function refer to the original caller of the proxy, not the proxy itself. This is correct behavior but must be understood to prevent security flaws.

  • Sub-step 1: Trace the flow of msg.sender. In any function that performs access control (e.g., onlyOwner), verify the modifier checks the correct address. The implementation's owner variable should be stored in the proxy's storage, not a hardcoded address.
  • Sub-step 2: Check msg.value handling in payable functions. If the implementation's function is payable and uses msg.value, ensure the proxy correctly forwards any attached Ether. The entire call chain must be payable.
  • Sub-step 3: Identify functions that use address(this).balance. This will return the proxy contract's balance, not the implementation's (which is typically zero). Logic depending on the implementation's balance will fail.
solidity
// Correct access control using proxy storage contract Implementation { function adminAction() public { require(msg.sender == StorageSlot.getAddressSlot(_ADMIN_SLOT).value, "Not admin"); // Action } }

Tip: Misunderstanding msg.sender can lead to broken authorization. Always test permissions from an end-user EOA through the proxy, not by calling the implementation directly.

3

Check for Uninitialized Proxy Contracts

Ensure proxy state variables, especially the implementation address, are properly initialized.

Detailed Instructions

An uninitialized proxy has a zero address (address(0)) as its implementation, making all delegatecalls fail or target unintended contracts. This is a critical setup vulnerability.

  • Sub-step 1: Review the proxy constructor and initialization function. Proxies are often deployed with empty constructors. Verify a separate initialize function exists and sets the _implementation address and other admin variables.
  • Sub-step 2: Ensure initialization is protected. The initialize function should include an access control modifier (e.g., onlyAdmin) or a check that the state is uninitialized (e.g., require(_implementation == address(0))) to prevent re-initialization attacks.
  • Sub-step 3: Verify initialization call on deployment. In deployment scripts, confirm that the initialize function is called atomically after the proxy is created, often via a factory pattern. Check for frontrunning opportunities between deployment and initialization.
solidity
// Example initialization vulnerability function initialize(address _impl) public { implementation = _impl; // No initializer check - can be called by anyone to hijack proxy }

Tip: Use OpenZeppelin's Initializable contract with the initializer modifier to guard against multiple initializations and ensure proper setup.

4

Analyze Function Selector Clashing

Detect collisions between proxy and implementation function signatures.

Detailed Instructions

Function selector clashing happens when a function in the proxy (like upgradeTo(address)) has the same 4-byte selector as a public function in the implementation. This can block upgrades or cause unintended execution.

  • Sub-step 1: Generate function selectors for the proxy. List all external/proxy functions (e.g., upgradeTo, admin, transferOwnership). Calculate their selectors using bytes4(keccak256("upgradeTo(address)")).
  • Sub-step 2: Generate selectors for the implementation. Get the selectors for all public/external functions in the current and any future plausible implementation versions.
  • Sub-step 3: Check for overlaps. Compare the two lists. Any match is a critical clash. The proxy's fallback() function delegatecalls to the implementation, but if a call matches a proxy function's selector, it will execute the proxy's function instead.
solidity
// Proxy with a clashing function contract Proxy { function admin() external view returns (address) { ... } // Selector: 0xf851a440 fallback() external { delegatecall(implementation); } } // Implementation with clashing function contract Implementation { function admin() external { selfdestruct(...); } // Same selector: 0xf851a440 -> CALLS PROXY, NOT DELEGATECALL }

Tip: Use transparent proxy patterns (like OpenZeppelin's) which route calls based on msg.sender being an admin or a regular user, or use the EIP-1967 proxy standard which avoids defining conflicting functions.

5

Verify Upgrade Mechanism Security

Assess the controls and process for changing the implementation contract address.

Detailed Instructions

The upgrade mechanism is the most privileged operation in a proxy system. A compromised upgrade can replace the logic with malicious code.

  • Sub-step 1: Review access controls on the upgrade function. Common functions are upgradeTo(address newImplementation). Verify they are guarded by a strong multi-signature wallet or a well-secured governance contract. A single EOA owner is a centralization risk.
  • Sub-step 2: Check for upgrade timelocks. A sudden upgrade can be an attack vector. Look for a mandatory delay between proposing and executing an upgrade, allowing users and monitors to react.
  • Sub-step 3: Validate the new implementation contract. The upgrade function should include checks that the newImplementation address is a contract (code.length > 0) and potentially verifies a pre-agreed hash of the bytecode. Ensure there is a process to test the new implementation on a testnet or via a staging proxy before mainnet deployment.
solidity
// Example of a basic upgrade function with a timelock function upgradeTo(address _newImpl) external onlyGovernance { require(_newImpl.code.length > 0, "Not a contract"); require(block.timestamp >= upgradeScheduledFor, "Timelock not expired"); implementation = _newImpl; }

Tip: Consider using the UUPS (EIP-1822) proxy pattern where the upgrade logic resides in the implementation, allowing the proxy to be simpler and potentially cheaper, but requiring extra care to keep the upgrade function secure across versions.

best-practices

Development and Auditing Best Practices

Essential strategies for securely implementing and reviewing delegatecall-based proxy patterns to prevent critical vulnerabilities.

01

Storage Layout Management

Storage collision is a primary risk. The proxy and implementation contract must have identical variable ordering and types in their initial storage slots.

  • Use structured inheritance or unstructured storage patterns.
  • Document layout changes meticulously across upgrades.
  • Auditors must verify slot alignment to prevent data corruption.
02

Initialization and Reentrancy Guards

Initializer functions replace constructors, which are ineffective in proxies. These must be protected from re-execution.

  • Implement a initializer modifier using a boolean flag or OpenZeppelin's Initializable.
  • Guard the proxy's fallback function against reentrancy during delegatecall execution.
  • This prevents contract hijacking during the setup phase.
03

Transparent vs UUPS Proxies

Choosing the correct proxy pattern is critical. Transparent proxies separate admin and logic calls in the proxy itself. UUPS (EIP-1822) proxies embed upgrade logic in the implementation.

  • Transparent proxies prevent function selector clashes for the admin.
  • UUPS saves gas and requires the implementation to be upgradeable.
  • Auditors must check upgrade authorization and logic self-destruct risks in UUPS.
04

Implementation Contract Security

The logic contract must be designed for delegatecall context. selfdestruct or delegatecall within the implementation can destroy or compromise the proxy's state.

  • Rigorously audit implementation for any opcode that uses address(this).
  • Treat selfdestruct and delegatecall as extremely high-risk in upgradeable contracts.
  • Use static analysis tools to flag dangerous opcodes in the logic.
05

Comprehensive Testing Strategy

Testing must simulate the proxy environment. Integration tests should deploy the full proxy system, not just the implementation in isolation.

  • Test all upgrade paths, including rollbacks and implementation migrations.
  • Use fork testing on mainnet state to check for integration issues.
  • Fuzz test function calls through the proxy to uncover storage assumptions.
06

Audit Checklist for Proxies

A focused review must verify the upgrade mechanism's integrity. Key checks include admin privilege escalation, initialization locking, and storage layout consistency.

  • Confirm only authorized addresses can upgrade.
  • Verify the initializer cannot be called twice.
  • Use tools like slither or solc to map storage variables and detect collisions automatically.

Frequently Asked Questions

The primary risk is storage collision, where the proxy and implementation contracts share the same storage layout. If the implementation's variable slots don't align perfectly with the proxy's, a delegatecall can corrupt critical proxy state like the admin address. For example, if the proxy stores the admin at slot 0 and the implementation mistakenly writes user data to slot 0, an attacker could overwrite the admin. This can lead to a complete loss of contract control, as seen in the Parity Wallet hack where over $150M was frozen.