by Zoraiz Mahmood (https://twitter.com/0xZorz)
If you can’t answer the following question confidently and explain why, you’ll learn something from this write-up.
For the following Solidity contract, what does name()
return after setName()
is called in the test below the contract:
Users may look at this test and think, wait a second, two arguments are provided for a function that should only take 1 argument. This should fail! Well, it doesn’t. Let’s figure out why.
Solidity as a language, does a lot of syntactic sugaring for us, so we need to figure out what’s actually going on under the hood. Recall that ultimately, the Ethereum Virtual Machine (EVM) only cares about various permutations of opcodes and precompiles. The best way to figure out what’s being interpreted by the EVM without stepping through the bytecode is to look at the Yul representation of the smart contract. In Foundry, we can do this as follows:
This will output the following Yul code, which gives us better insights into what’s going on:
In the first code block, we have the init code. Glossing over, it enforces that the constructor is not payable by checking that there is no callvalue()
. It then returns the runtime code which has the core logic.
The runtime code is the core logic loop that will be executed anytime someone attempts to make a call
to this smart contract. In the code above, it is labeled as the object MockPresetA_22875_deployed
.
Let’s step through the key components of the runtime code.
Memoryguard is used by the Yul optimizer, telling the optimizer that this application promises to only use the memory range starting at 0x80.
0x80 is chosen because the first four 32-byte slots are reserved by solidity.
We then execute mstore(64, _1)
. 64 in hex is 0x40, which is the location of the free memory pointer and doesn’t change, as seen from the diagram above. Essentially, we are keeping track of the next free memory slot using the free memory pointer. This is a convenient way of making sure we do not accidentally overwrite anything already allocated in memory.
The next code block is a check on the calldata
This code block will revert if the size of the calldata is less than four bytes.
Why four? Solidity derives the function selector from the first four bytes of the keccak hash of the signature of the function.
For our original smart contract, this can be derived explicitly as such:
If we had a fallback function in our original smart contract, we would not immediately check that the calldata size was at least 4 bytes, since there would exist a function on the smart contract that does not require at least 4 bytes of calldata. However, in our smart contract MockPresetA, every possible function call requires a function selector, which is why we revert if it doesn’t exist.
Next, let’s dive into the “if (…)”
block
We track the value 0 in variable `_2` as an optimization since the value 0 is used several times in the code block.
We then extract the 4-byte function selector from the calldata. calldataload(0)
will give us a 32-byte payload starting from the 0’th index of the calldata.
By shifting this payload right 28 bytes (224 in decimal), we get the first 4 bytes of the 32-byte payload, which is, of course, the function selector.
Now that we have the function selector, we match it against the possible functions the user may be trying to call. Recall the calculation of the function selector we did earlier. If the user is trying to call name()
the function selector will be 0x06fdde03
. If they are trying to call setName(bytes32)
, the function selector will be 0x5ac801fe.
Let’s focus on 0x5ac801fe, ie. setName(bytes32).
If the callvalue() is non-zero, we revert. This is because the function is not marked as payable.
We then have this convoluted line
Working outwards, not(3) equal to -4, when converted using two’s complement.
Then in the section add(calldatasize(), -4)
, we are reducing the calldata size by 4, and checking if the result is less than 32. If it is less than 32, we revert.
This is essentially a check to ensure we have AT LEAST one 32-byte argument. This is the reason we would revert if we did not provide any arguments to the setName
function call.
Finally, we take the 32-byte payload starting after the 4’th byte (to exclude the function selector) and save that in storage slot 0 using sstore
before returning.
The interesting thing to note is that any additional bytes after the first 36 bytes (function selector + 32-byte argument) are completely ignored.
Looking at the original question, let’s see if we can be more confident with our answer. What does name()
return in our test?
Of course, it will return “Hello World!”. The second argument is completely ignored. The same principles apply to the constructors of contracts.
Thanks for reading, make sure to follow me on Twitter and subscribe to the newsletter.
not(3) is 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc, I think.