Solidity delegatecall usage and pitfalls
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 EntryPointContract
will 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.