Originally published at: https://blog.openzeppelin.com/deep-dive-into-the-minimal-proxy-contract/
To go over the EIP 1167: Minimal Proxy Contract, my approach will be different to what you might expect. The challenge is to build a minimal proxy ourselves, from scratch, and with no Solidity code involved. In the process, we’ll learn how many EVM instructions work and hopefully will never be scared of this ugly sequence of bytes again:
If you just want to know how to integrate a minimal proxy into your project, go straight to How to deploy minimal proxies.
Why a minimal proxy?
Say you need to deploy a wallet for each user your dApp onboards. Or perhaps, you need to set up an escrow for each trading operation your platform processes.
These are just examples where you’d have to deploy the same contract multiple times. The initialization data is of course likely to be different for each individual contract, but the code would be the same.
Because deploying large contracts can be quite expensive, there’s a clever workaround through which you can deploy the same contract thousands of times with minimal deployment costs: It’s called EIP 1167, but let’s just call it Minimal Proxy.
Many in the community still find this EIP rather obscure, scary, or plainly impossible to understand. It makes sense, considering it’s written in a daunting EVM low-level code. So, the idea of this article is to cover the standard from scratch and shed enough light on it to set the community free from their fears and achieve world peace 🌈.
Minimal means minimal
To recap, the rationale behind the EIP is that instead of deploying a huge contract multiple times, we just deploy a super-cheap minimal proxy contract that points to the huge contract already on chain.
Minimal means minimal. That is, all the proxy contract will do is delegate all calls to the implementation – nothing more, nothing less. Make sure you do not confuse EIP 1167 minimal proxy contracts with the proxy pattern used for contract upgrades.
EIP 1167 has nothing to do with upgradeability nor tries to replace it.
A proxy from first principles
To begin with, let’s think for a moment what a minimal proxy needs to do:
- Receive some data
- Forward the received data to an implementation contract using the DELEGATECALL instruction.
- Get the result of the external call (i.e., the result of DELEGATECALL)
- Return the external call’s result to the caller if step 3 succeeded, or revert the transaction in any other case.
And that’s it! So let’s try to very roughly map these four steps into the EVM instructions:
|What we want||EVM instruction||Short explanation|
|Parse the data received from caller||CALLDATACOPY||calldata is the data sent to the contract in the transaction, so we need to copy that into memory to be able to later forward it.|
|Execute a DELEGATECALL to the implementation contract||DELEGATECALL||No surprise with this. Here, we’ll forward the calldata obtained to the implementation contract.|
|Retrieve the result of the external call||RETURNDATACOPY||We copy the returned data of the DELEGATECALL into memory.|
|Return data to the caller or revert the transaction||JUMPI, RETURN, REVERT||Depending on the success/failure status of the external call, we either return data or revert the transaction.|
Well, that wasn’t THAT difficult, right? We’re already halfway there! We just need to solve some minor implementation details now.
Path to the minimal proxy
Remember these 4 steps. Get the calldata, delegate the call, get the data returned, and return or revert. Easy peasy.
Let’s assume that we start with an empty stack and a clean memory.
1. Get the calldata
- Main instruction: CALLDATACOPY
- Formal description: Copies input data in current environment to memory.
- It has 3 arguments:
|#||Argument||What we’ll pass|
|1||Memory slot where calldata will be copied||0|
|2||Where that data begins||0|
|3||How much data we want to copy||CALLDATASIZE, as we want to copy all calldata|
Note that to obtain the size of calldata, we can use the handy instruction CALLDATASIZE.
Remember, we’re working at the EVM level, so before we call CALLDATACOPY, we need to manually prepare the stack with the arguments to be passed. We must get to a stack that looks like:
[ 0 | 0 | calldata size ("cds" from now on) ]
So, we could just simply do:
|3d||RETURNDATASIZE||0 0 cds||–|
|37||CALLDATACOPY||–||[0, cds] = calldata|
Why do we use RETURNDATASIZE? Well, we need to push two zeros into the stack. Ideally you’d just use the PUSH instruction to do it, but here is the catch: A PUSH costs 3 units of gas, whereas RETURNDATASIZE costs only 2.
Awesome! Step 1 accomplished. Calldata is in memory.
2. Delegating the call
- Main instruction: DELEGATECALL
- Formal description: Message-call into an account with an alternative account’s code while persisting the current values for sender and value.
- It takes six arguments:
|#||Argument||What we’ll pass|
|1||How much gas we want to forward||All, using the GAS instruction|
|2||Address of the contract the proxy delegates the call to||An address hardcoded in the minimal proxy’s bytecode (we’ll call it addr)|
|3||Memory slot where forwarded data starts||0|
|4||Size of forwarded data||cds|
|5||Memory slot where the returned data will be written||0 (we won’t write to memory but return it instead)|
|6||Size of returned data to write in memory||0 (we won’t write to memory but return it instead)|
So, we must get to a stack that looks like:
[ gas | addr | 0 | cds | 0 | 0 ]
Aaaand for a very specific reason (explained later), we’ll push one additional 0 to the stack. It won’t be used by DELEGATECALL, as it’s just a matter of overall efficiency. Therefore, the stack should actually look like this:
[ gas | addr | 0 | cds | 0 | 0 | 0 ]
The first 6 items are the arguments for the DELEGATECALL.
The best set of instructions that allows us to build the stack we need is as follows:
|3d||RETURNDATASIZE||0||[0, cds] = calldata|
|3d||RETURNDATASIZE||0 0||[0, cds] = calldata|
|3d||RETURNDATASIZE||0 0 0||[0, cds] = calldata|
|36||CALLDATASIZE||cds 0 0 0||[0, cds] = calldata|
|3d||RETURNDATASIZE||0 cds 0 0 0||[0, cds] = calldata|
|73 addr||PUSH20 0x123…||addr 0 cds 0 0 0||[0, cds] = calldata|
|5a||GAS||gas addr 0 cds 0 0 0||[0, cds] = calldata|
|f4||DELEGATECALL||success 0||[0, cds] = calldata|
Again, we don’t use PUSH to add zeros into the stack – we use RETURNDATASIZE because it’s cheaper. Don’t pay much attention to the memory column from the table above. It just contains leftovers from step 1.
Also note that DELEGATECALL consumes the first six items and pushes the result (named success) into the stack. The 0 that’s left in the stack is the zero we pushed earlier for reasons we’re about to understand.
Now, we have executed a DELEGATECALL to the implementation contract forwarding the calldata that we got in step 1. Great! Let’s move on to step 3.
3. Get the result of an external call
Based on the item that the DELEGATECALL pushed into the stack, we can tell whether the call was successful or not. But what if the external call returned some kind of data, perhaps an error message or a return value from a function?
The EVM provides us with a specific instruction that will help us retrieve just that.
- Main instruction: RETURNDATACOPY
- Formal description: Copy the output data from the previous call into the memory.
- We need to specify 3 arguments:
|#||Argument||What we’ll pass|
|1||Where in memory we want to copy the returned data||0|
|2||Start of the returned data||0|
|3||How much of the return data we want to copy||size of the returned data after the external call (“rds” from now on)|
For the last argument, we will use the result of RETURNDATASIZE. Bear in mind that now, as we have executed an external call, it may not return 0 as it used to.
To proceed, we need a stack where the first three items look like:
[ 0 | 0 | rds ]
Remember that the stack still has two items that were left behind after step 2, so currently, it looks like:
[ success | 0 ]
Therefore, the best set of instructions to write
[ 0 | 0 | rds ] on top of the stack and execute the RETURNDATACOPY instruction is:
|3d||RETURNDATASIZE||rds success 0||[0, cds] = calldata|
|82||DUP3||0 rds success 0||[0, cds] = calldata|
|80||DUP1||0 0 rds success 0||[0, cds] = calldata|
|3e||RETURNDATACOPY||success 0||[0, rds] = return data (there might be some irrelevant leftovers in memory [rds, cds] when rds < cds)|
The first three items
[ 0 | 0 | rds | ... ] after executing the DUP1 are the arguments for RETURNDATACOPY, which writes all returned data into memory starting at slot 0 (partially or completely overwriting what was in memory at those slots).
We have successfully copied all returned data from the DELEGATECALL into memory. Note that we left two items in the stack, which we’ll use in the final stage.
4. Final stage: return or revert
We received some data, then we executed a DELEGATECALL, and we finally copied the returned data into memory. It’s time to make the big final decision: Should we return or revert?
It all depends on whether the success item we have on the stack is a 0 or not.
- If success is 0, the DELEGATECALL has failed and we have to revert.
- If success is not 0, the DELEGATECALL has succeeded and we have to return.
In EVM language, an if condition can be represented using JUMPI. But before jumping anywhere, we must prepare.
Whatever is in memory now at [0 – rds] needs to be sent back to the caller, either by a REVERT or by a RETURN instruction. Both instructions take two memory pointers as parameters. This means that at some position in the stack we need to have:
[ 0 | rds ]
To reach a REVERT or a RETURN, we need to use a JUMPI instruction, which first requires knowing the destination of the jump and the condition to evaluate (in our case, the success item already in the stack). Because JUMPI necessarily comes before the REVERT or the RETURN, our stack should look like:
[ dest | success | 0 | rds ]
The first two items are arguments for JUMPI, and the remaining two are arguments for either REVERT or RETURN. For now, dest is just a placeholder of a bytecode instruction’s position, which can only be defined in hindsight.
If our current stack is:
[ success | 0 ]
One minimal set of instructions that could take us to the desired stack and that jumps to dest, depending on the success item, is:
|90||SWAP1||0 success||[0, rds] = return data|
|3d||RETURNDATASIZE||rds 0 success||[0, rds] = return data|
|91||SWAP2||success 0 rds||[0, rds] = return data|
|60 dest||PUSH1 dest||dest sucess 0 rds||[0, rds] = return data|
|57||JUMPI||0 rds||[0, rds] = return data|
After the jump, the execution must either reach a REVERT when success is 0 (no jump) or a RETURN.
|fd||REVERT||–||[0, rds] = return data|
|5b||JUMPDEST||0 rds||[0, rds] = return data|
|f3||RETURN||–||[0, rds] = return data|
I hope you’ve kept the count of how many bytes our code has so far… have you? I told you before that dest could only be defined in hindsight – now is the time to do it.
The entire set of instructions for this EVM runtime code is made up of 45 bytes, and JUMPDEST occupies position 43. In hex, it’s position 2b. That’s why, in the specification of the EIP, you’ll see 2b where we’ve used dest.
The final runtime code of a minimal proxy we’ve built from scratch, following EIP 1167, is:
363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3, where the bytes at indices 10-29 (inclusive) must be replaced by the 20-bytes address of the implementation contract.
We’re done, right?
Well… no 😉
The creation code
Up to this point, we coded the “runtime” code of the EIP 1167. That’s the code of a deployed minimal proxy. However, it only takes you one failed transaction to realize that this code cannot be used to deploy a minimal proxy. For that, we will need the creation code.
For an explanation on the difference between runtime code vs. creation code, please refer to the EVM code bible by the one and only Ale Santander: “Deconstructing a Solidity Contract – Part 2: creation vs. runtime”.
What we need now is the set of EVM instructions that will return and put this runtime code in the blockchain. Luckily, it’s fairly straightforward:
- Copy the runtime code into memory.
- Get the code into memory and return it.
Copying runtime code into memory
We already have our runtime code, built in the previous section.
Now, our job is to put together a set of instructions that will throw that long sequence of bytes into memory. Unsurprisingly, the EVM provides us with an instruction to do so:
- Main instruction: CODECOPY
- Formal description: Copy code running in current environment to memory.
- It takes 3 arguments:
|#||Argument||What we’ll pass|
|1||Where in memory we want to copy the code||0|
|2||Position where the code to be copied starts||10 (0a in hex)|
|3||Length of the sequence of bytes to copy||45 (2d in hex)|
Why 45? It’s the number of bytes of the runtime code. Why 10? You’ll see. All in all, to execute the CODECOPY, we must get to a stack that looks like:
[ 0 | 0a | 2d ]
One set of instructions (following the EIP) that would take us there is:
|602d||PUSH1 2d||2d 0||–|
|80||DUP1||2d 2d 0||–|
|600a||PUSH1 0a||0a 2d 2d 0||–|
|3d||RETURNDATASIZE||0 0a 2d 2d 0||–|
|39||CODECOPY||2d 0||[0-2d]: runtime code|
And what are these
0 left on the stack after the CODECOPY? Well, those are for the upcoming RETURN instruction, which takes
2d as arguments.
|81||DUP2||0 2d 0||[0-2d]: runtime code|
|f3||RETURN||0||[0-2d]: runtime code|
Notice there’s a
0 left on the stack. This means that actually the creation code of the minimal proxy can be made even more efficient. Can you guess how? Which instructions would you change? Don’t the first RETURNDATASIZE and the last DUP2 look like good candidates?
This set of instructions is 10 bytes long. That’s why we passed a 10 (
0a in hex) as a second argument to the CODECOPY instruction. Finally, the entire sequence of bytes representing the creation code, which includes the runtime code, is:
The bytes at indices 20 to 39 (inclusive) are to be replaced by the 20-bytes address of the logic contract.
How to deploy minimal proxies
It’s very simple! Since version 2.4, the OpenZeppelin’s SDK puts the whole power of minimal proxies right at your fingertips. Once you have initiliazed your project and included some contracts, all it takes is a simple
$ oz create --minimal
If you want to deploy EIP 1167 minimal proxies from a Solidity contract, you have two options:
- Use the OpenZeppelin SDK, which provides you with a reusable
- Use the
CloneFactorycontract provided along with the EIP.
Thanks to the great Andres Bachfischer who joined me in the adventure and helped me review this article