Demystifying Solidity’s call and delegatecall functions: Understanding the Differences and Security Pitfalls

Sergio Mazariego
10 min readMay 21, 2023

--

Table of content

– Introduction
– Defining call and delegatecall
– Examples using call and delegatecall
– Different ways to use call and delegatecall
– Return values in call and delegatecall
– Call vs delegatecall use cases
– Security considerations when using call or delegatecall
– Some Differences and Similarities
– Conclusions

Introduction

In this blog post, I want to describe the use cases of call and delegatecall, as well their differences, how smart contracts can use them, and of course, security considerations for developers and smart contract auditors when their encounter them in the wild.

If you want to connect with me, follow me on twitter at @s3rgiomazari3go, my DMs are open to help.

Defining call and delegatecall

Before get started, lets define each of the functions:

Call: The call function in Solidity is a low-level function that allows a contract (caller) to invoke functions of another contract (callee), it operates in the context of the caller, executing code in the called contract while preserving the storage and context of the caller, upon invocation, the call function returns a boolean value indicating the success or failure of the function call, along with a stream of bytes containing any return data from the called contract (more on that later).

Delegatecall: The delegatecall function is also a low-level function in Solidity that permit a contract (caller) to invoke functions of another contract (callee) with some notable distinctions, unlike call, delegatecall executes the code of the callee in the context of the caller, this means that it inherits the storage and context of the caller, potentially modifying its state, similar to call, the delegatecall function returns a boolean value indicating the success or failure of the function call.

Let’s consider a scenario where contract A acts as the caller and contract B as the callee, contract A has functions that use call and delegatecall to set a value in a variable of contract B, with call, contract A can invoke a function in contract B, passing the value to be set as an argument, the function in contract B is executed in its own context, and the value is stored in its own storage.

However, In a delegatecall, the function of the contract B is executed within the context of the contract A, this means that any modifications made to the storage within the function called via delegatecall will affect the storage of the contract A, not the contract B, the callee contract can read and write to the caller contract’s storage.

The choice between call and delegatecall depends on whether the callee contract should operate in its own context (call) or modify the caller’s storage (delegatecall).

Examples using call and delegatecall

In Contract B, we define a simple contract with a value variable and a function setValue that updates the value.

In Contract A, we have two functions: callSetValue and delegateCallSetValue. These functions accept an address of another contract and a new value.

  • callSetValue uses the call function to invoke the setValue function of the specified contract, it operates in the context of Contract A and updates the storage of the called contract, any state changes occur in the called contract, and the calling contract’s storage remains unaffected.
  • delegateCallSetValue uses the delegatecall function to invoke the setValue function of the specified contract, it operates in the context of the calling contract (Contract A) and updates the storage of Contract A itself, the storage and context of the called contract are inherited by the calling contract, potentially affecting its state.

By deploying Contract A and Contract B, and calling the respective functions, you can observe the differences in how the value variable is updated and which contract's storage is affected.

Why is it necessary for Contract A to replicate the order of variables in Contract B when using delegatecall, while there is no issue with changing the variable order in both contracts when using a regular call?

In delegatecall, it is necessary for Contract A to replicate the order of variables in Contract B because the called contract’s code is executed within the context of the calling contract, this means that Contract B operates using Contract A’s storage layout, and any modifications to Contract A’s storage must adhere to its original variable order, mismatching the variable order between the two contracts can lead to incorrect data interpretation and unexpected behavior.

On the other hand, in a regular call, the code is executed within the context of the called contract itself, each contract has its own storage layout and variable order.

In other words, in delegatecall, the called contract operates within the calling contract’s context, requiring variable order replication to ensure proper data handling, but in a regular call, each contract operates within its own context, allowing flexibility in variable order as long as it remains consistent within each contract.

Different ways to use call and delegatecall

There are different ways to use call and delegatecall in a contract, in this section, I will share what in my opinion, are the most common and necessary to understand.

Spoiler: When you use delegatecall, you cannot send eth directly with the function invocation.

Calling an other contract function

Calling an other contract function, sending value (eth)

Calling a function with one argument

Calling a function with two arguments of the same data type

Calling a function with two arguments of different data type

Return values in call and delegatecall

When using the call and delegatecall methods in Solidity, the return value indicates the success or failure of the function call, the return value is a tuple consisting of two elements: a boolean value and the return data.

The boolean value indicates whether the function call was successful or not, it will be true if the call was successful and false if it failed, a call can fail for various reasons, such as an exception being thrown within the called function, an out-of-gas condition, or if the called contract’s fallback function does not exist or reverts.

The return data is of type bytes memory and contains any data returned by the called function, if the function being called has a return value, it will be encoded and stored in the returnData variable. You can then decode and interpret the return data according to the expected return type of the called function.

Here’s an example of how you can handle the return value of a call or delegatecall:

In the example above, we capture the return value of the call method in the tuple (bool success, bytes memory returnData) we then check the success boolean to ensure that the function call was successful, if it failed, we can handle the failure accordingly, such as reverting the transaction or taking other necessary actions.

If the returnData has a length greater than zero, it means that the called function returned some data, we can decode and process this data based on the expected return type, the specific decoding and processing logic will depend on the return type of the called function.

Another example

In this code, after storing the return data in returnData, the abi.decode function is used to decode the return data, we specify the expected types (uint256, string) in the second parameter of abi.decode to decode the return data accordingly.

By including abi.decode, the return data is properly decoded and assigned to the processedNumber and processedString variables, this allow us to return the processed results as a tuple of uint256 and string.

It’s important to note that when using delegatecall, the return data will reflect the return value of the function being called, but the context and storage will be that of the calling contract rather than the called contract.

Call vs delegatecall use cases

Now that we know about how call and delegatecall can be used, in this section I want to point to some of the most common use cases these functions.

Call: The call function is commonly used when you want to invoke functions from external contracts or addresses, it operates in the context of the calling contract, preserving the storage and context of the caller, this makes it useful for scenarios where you need to interact with external contracts but want to maintain control over your own contract’s state, additionally, call can be used to send Ether along with the function call, making it suitable for performing payments or transferring value during function invocations.

Some common use cases for call include:

  1. Accessing external contract functions: You can use call to invoke specific functions of other contracts and retrieve their return values.
  2. Oracles and data retrieval: External data sources can be accessed using call to fetch information from APIs or external systems.
  3. Interacting with contract interfaces: When working with contracts that implement specific interfaces, call allows you to invoke interface functions and retrieve data or trigger actions.

Delegatecall: On the other hand, delegatecall is a powerful function invocation mechanism that executes code from the called contract in the context of the calling contract, this means it inherits the storage and context of the caller, allowing it to directly modify the state of the calling contract, this can be advantageous when you want to integrate reusable libraries or delegate certain functionalities to external contracts while maintaining the same storage in the caller contract.

Here are some common use cases for delegatecall:

  1. Library integration: By using delegatecall, you can incorporate external libraries into your contract, enabling code reuse and reducing deployment costs, the library’s code will be executed in the context of your contract, allowing it to access and modify your contract’s storage.
  2. Upgrading contracts: delegatecall can be leveraged for contract upgrades, where a new contract logic is deployed and then called via delegatecall from the existing contract, this way, the new logic operates within the context of the existing contract, preserving the storage and state.
  3. Modular contract design: delegatecall facilitates modular contract design by allowing separate contracts to be invoked and operated within the context of a single contract, enabling modular functionality and reducing contract complexity.

Security considerations when using call or delegatecall

When utilizing the call and delegatecall functions in Solidity, it is vital to understand and address the associated security considerations, both these function invocation mechanisms can introduce potential risks if not used carefully, in this section, we will explore some essential security considerations to keep in mind when employing call and delegatecall in your smart contracts, you can find the details in the references section.

Untrusted Contract Interaction: When using call or delegatecall to interact with external contracts, ensure that you carefully validate and sanitize any input or data received from these contracts, external contracts may contain malicious code or vulnerabilities that can be exploited, validate inputs, perform proper boundary checks, and use safe data handling practices to prevent potential security breaches.

Storage Protection: delegatecall allows external contracts to modify the storage of the calling contract directly, while this feature can be powerful for modular design and contract upgrades, it also introduces risks, be cautious when using delegatecall and ensure that you protect critical or sensitive storage variables from unintended modifications, implement appropriate access control mechanisms and validation checks to prevent unauthorized modifications to the calling contract’s storage.

Gas Limit and Out-of-Gas Attacks: Both call and delegatecall operations consume gas, and it’s crucial to consider the potential gas usage when making function invocations, large or complex operations performed via call or delegatecall can consume excessive gas, leading to out-of-gas scenarios, this can result in failed transactions or unexpected behavior, thoroughly analyze gas requirements, implement gas limits, and conduct comprehensive testing to mitigate gas-related risks.

Reentrancy Attacks: Carefully consider the potential for reentrancy attacks when using either call or delegatecall, reentrancy occurs when an external contract maliciously invokes back into the calling contract before the initial invocation completes, this can lead to unexpected behavior and may result in financial losses or unauthorized access, implement proper mutex or reentrancy guard patterns to prevent such attacks and ensure robust security.

Some Differences and Similarities

Conclusions

In conclusion, understanding the differences between call and delegatecall is crucial for Solidity developers, call preserves the caller’s storage and context, while delegatecall modifies the caller’s storage, properly handling return values and considering security measures are important when using these function invocation mechanisms, by understanding these concepts, developers can effectively use call and delegatecall in their smart contracts while mitigating security risks, always follow best practices and refer to the Ethereum Smart Contract Best Practices guide for more information on secure Solidity development.

References:

Ethereum Smart Contract Best Practices

Delegatecall vulnerabilities in Solidity

yAcademy Proxies Research

--

--

Sergio Mazariego

Security Researcher, I write about Cybersecurity, Digital Forensics, Offensive Security and Web 3.0.