Solidity delegatecall usage and pitfalls

Jeremy Then
8 min readAug 17, 2022

Solidity delegatecall is a low-level function that allows us to load and call the code of another contract while preserving the context of the main contract. This means that the code of the called contract is executed but any state changes made by the called contract are actually made on the main contract storage, not on the called contract’s storage.

This is useful to create libraries and the proxy contract pattern, where we delegate the call to a different contract, “giving it” permission to modify the state of the calling contract.

This function also has some pitfalls that we need to be aware of and is basically what this article will focus on.

As explained in another article regarding the layout of state variables in storage, every state variable declared in a contract occupies a slot in the storage, potentially sharing a common slot with other state variables if their type is smaller than 32 bytes and they can fit together in a single slot.

So, when we access these state variables, to assign values to them or to read from them, Solidity uses the declaration position of that state variable to know what storage slot to access and read from it or update it.

For example, given the following contract:

We see that it declares 3 state variables, owner, id and updatedAt. These state variables have some values assigned, and in storage, they would look something like this:

We see that at slot index 0 we have the value of the first state variable, left padded with zeros because each slot holds 32 bytes of data.

The second slot, which has index 1, has the value of the id state variable.

The third slot, with index 2, has the value o the third state variable updatedAt. All the data in storage is represented as hexadecimal, so converting 0x62fc3adb to decimal is 1660697307, which converted to date with js:

Produces:

Tue Aug 16 2022 20:48:27 GMT-0400 (Atlantic Standard Time))

So, when accessing the state variable id, we are accessing the slot with index 1.

Good, so, where do the pitfalls of using delegatecall comes into play?

For a delegated contract to make changes to the main contract’s storage, it needs to declare variables of its own, in precisely the same order the main contract declares it, and usually the same amount of state variables.

For example, a delegated contract for the EntryPointContract shown above, would look like this:

With exactly the same state variables, of exactly the same type, in exactly the same order, and preferably with exactly the same count of state variables. In this case, 3 state variables each.

Let’s show both contracts:

Here we see an implementation of a really simply proxy contract. The EntryPointContract has a constructor that receives the address of a deployed DelegateContract to delegate its calls to and has its own state modified by this DelegateContract.

The delegate function receives a _newId to be set, so it uses the low-level delegatecall to delegate that call to the DelegateContract , which in turn updates the id variable.

After calling the delegate function with a new id value, and checking the values of both EntryPointContract and DelegateContract , we see that only the state variable id of the EntryPointContract has value, but the id state variable of the DelegateContract has no value, and is still set to 0 because the storage DelegateContract modified was not its own, but the storage of EntryPointContract .

Good!

In line 7, we see id = _newId, but, as weird as it may sound, it is not actually modifying the id variable of EntryPointContract, but the storage slot of EntryPointContract where the id state variable in EntryPointContract is declared. And we know that the id variable in EntryPointContract is declared in the slot with index 1, as shown above.

This may be confusing because we actually see that we are assigning a value to the id variable in DelegateContract and we may think that no matter where this variable is positioned in the EntryPointContract or DelegateContract it would still modify that id state variable slot in the EntryPointContract. But no, this is not always the case.

For example, in the following contract I declare the id state variable in DelegateContract in the third position, which means now that it points to the slot with index 2, and leave the id state variable in EntryPointContract exactly where it was.

Now, what would happen if I call delegate again with a new id value of 15?

Let’s see…

This DelegateContract is deployed at: 0x2eD309e2aBC21e6036584dD748d051c0a6E03709

We can analyze it using Remix:

EntryPointContract is deployed at (Rinkeby): 0x172443F1D272BB9f6d03C35Ecf42A96041FabB09

And we can check its values with Remix:

Great!

Now let’s call delegate with the value 15 and see what happens. This will call the EntryPointContract with a new id value and the EntryPointContract is assigning its id state variable with this new value.

Let’s check DelegateContract state variables values:

No change, as expected, because it is not supposed to alter its own state because it was delegated the state of the EntryPointContract.

Let’s check the EntryPointContract state variables values (remember that we are expecting the id to now be 15 and everything else to stay the same):

Oh oh! The id of the EntryPointContract is still 5 and the state variable that was actually affected was updatedAt. What?

As I explained above, the DelegateContract is not actually modifying the state variables by their names, but by their declaration position in storage.

We know that the id state variable is declared second in the EntryPointContract, which means that it will occupy the slot with index 1 in the storage. updatedAt is declared third in EntryPointContract, thus occupying storage slot with index 2. But we see that DelegateContract declares the id variable third, while it declares the updatedAt second. So, when DelegateContract tries to modify id , it is actually modifying the slot with index 2 of the EntryPointContract storage, which is where the updatedAt state variable is located in the EntryPointContract. That’s why we see that updatedAt is the one updated, not id.

Let’s illustrate it:

EntryPointContract storage showing the order of the declared state variables and their values.

EntryPointContract storage “sent” to (delegated) DelegateContract, showing the state variables in the order they were declared in DelegateContract but showing the values in the order EntryPointContract state variables were declared.

So, we clearly see that in the DelegateContract the id variable is actually pointing to the updatedAt value in the EntryPointContract storage and that the updatedAt value of DelegateContract is actually pointing to the slot where the id variable has its value in the EntryPointContract storage.

So, this is the reason why we need to be really careful while delegating calls to another contract, since having the same variable types and names does not ensure that those variables in the calling contract will be the ones affected. They need to be declared in the same order in both contracts.

Another interesting fact is that the delegated contract can have more state variables than the main contract, effectively adding values to the main storage that it cannot access directly since it does not have a variable pointing to that storage.

To clarify that, let’s check these contracts:

We see that EntryPointContract still declares 4 state variables, while DelegateContract declares 5. And we know that when EntryPointContract delegates a call to DelegateContract it will send its own storage to DelegateContract. , but EntryPointContract does not have a fifth state variable (unreachableValueByTheMainContract). So, what happens when DelegateContract modifies this fifth variable that it declares but EntryPointContract does not declare?

Well, it will actually modify the slot index 4 (the fifth position) of the EntryPointContract storage. The EntryPointContractwill not be able to access it directly because it does not declare a state variable at that slot, but the value will be there and we can access it with something like web3.eth.getStorageAt(entryPointContractAddress, 4) .

EntryPointContract is deployed at 0xA80a6609e0cA08ed3D531FA1B8bbCC945b8ff409 and we see its values:

Now let’s call delegate, with the value 18:

Awesome! But where is the value 8 set to unreachableValueByTheMainContract? Let’s see if it’s in the DelegateContract state:

No, it’s not there. Because the DelegateContract did not modify its own state, even if the state variable is not declared in the EntryPointContract. But since the unreachableValueByMainContract state variable is declared in the fifth position (storage slot index 4), then it affected the EntryPointContract storage slot index 4 anyways. We can check its value directly:

web3.eth.getStorageAt("0xA80a6609e0cA08ed3D531FA1B8bbCC945b8ff409", 4)

Returns:

0x0000000000000000000000000000000000000000000000000000000000000008

Yes! There it is.

This is an interesting way of how a smart contract could be “extended” after deployment, simply by delegating its actions to another contract in the first place. This needs to be crafted and designed really well. The delegating contract addresses need to be able to be replaced dynamically if needed so that the entry point contracts can then point to a new implementation at any time.

There are ways around this, and one of them is the EIP-1967: Standard Proxy Storage Slots.

Conclusion

delegatecall is a powerful but tricky functionality, that, if used well, allows us to create “extendable” smart contracts to help us fix vulnerabilities and add new features to an existing smart contract, by making it delegate its actions to another contract dynamically and have its state modified by it. Logic to change the delegating contract at any time needs to be added, to be able to “extend” and fix potential vulnerabilities.

We need to keep in mind the order of the state variables in both the proxy contract and the implementation contract to avoid the modification of storage data that was not intended.

Follow me for more content related to Blockchain, Solidity, Web3, and DeFi.

--

--

Jeremy Then

I’m a full stack software developer and blockchain engineer, passionate about helping others learn software and achieve more.