Reverting transactions using inline assembly can be more gas-efficient than using the high-level Solidity revert or require statement. In this guide, we’ll explore how the different types of reverts in Solidity work under the hood by simulating their implementations in assembly.
The example below shows that the revert statement in the assembly version reduces the gas cost from 157 gas to 126 gas, saving 31 gas:
In the EVM, memory is a long array of bytes that are byte-indexed. That is, we can read and write bytes based on their index. Even though memory is bytes indexed, we typically read and write 32 bytes at a time.
mstore in assembly and how it works
Reverting with assembly depends heavily on the Yulmstore opcode to store data in memory, so let’s deeply explore that opcode first.
The mstore opcode takes two arguments:
Memory location: The byte address where the data will be stored.
Data: The 32-byte data to be stored.
The following is an example of how to use mstore:
assembly {
mstore(memoryLocation, dataToStore)
}
If you want to store 32 bytes of 0xFFat the memory location 0x00, you would write:
This shifts the start of the data by one byte, and the first byte at 0x00 will remain unaffected. The diagram below shows how mstore stores data in memory:
Note that even though we specified writing to byte index 0 in mstore(0, ...), we wrote to 0 — and also the following 31 bytes — mstore writes 32 bytes at a time.
Implicit data padding in mstore
If we specify fewer than 32 bytes (64 hex chars) in the second argument, the Solidity compiler will left-pad it with zeros (the more significant bytes) until the value is 32 bytes long, then it will write those 32 bytes starting at the byte index specified in the first argument of mstore.
Consider the following example:
assembly {
mstore(0x00, 0xff)
}
The code above stores the data as 0x00000000000000000000000000000000000000000000000000000000000000ff in memory, with 0xff occupying the last byte and the rest of the preceding 31 bytes filled with zeros.
In other words, mstore(0x00, 0xff) implicitly becomes mstore(0x00, 0x00000000000000000000000000000000000000000000000000000000000000ff)
The result of the value in memory is shown here:
Recall that mstore writes 32 bytes, but in this case, 31 of those bytes are zeros, spanning from the 0th byte to the 30th byte inclusive. This means any data within the byte range 0-31 will be overwritten with zeros.
We can see how it looks like in memory if we return the stored data as shown in the screenshot below:
The diagram below shows how mstore implicitly left-pads various hex values, with the first row being the example we just looked.
Using mstore8 to store data in memory
Alternatively, we can use the mstore8 opcode, which is similar to mstore but stores just one byte of data at a specific memory location.
For example, if we we want to store a single byte of data (0xff) at the 31st byte, we can directly store it using mstore8 like this:
assembly {
mstore8(31, 0xff)
}
And the output will be the same as using mstore, with 0xff occupying the last byte.
The key difference between mstore8 and mstore is that mstore8 doesn’t add 31 extra zeros that would overwrite previously stored data spanning from the 0th to the 31st byte, unlike mstore.
Writing 0xff with zeros on the right using mstore
If you want to write 0xff in the 0th byte using mstore instead of mstore8, you can store 0xff as the first byte and pad the remaining 31 bytes with zeros, as shown below:
This will store the value exactly as you specified, with 0xff at the beginning and the remaining bytes as zeros:
Here is a test run of the code in remix:
That seems like a lot of zeros, right? Alternatively, we can use mstore8 to store one byte of data at a specific memory location. In the example below, we used mstore8 to store 0xff at the 0th byte:
This is a much more compact code that does the same thing as the one in the previous screenshot, except that it does not write zeros into bytes 1 to 31.
Note that:
mstore stores a full 32 bytes of data, while mstore8 only stores a single byte at the specified memory location.
When using mstore, if your data is fewer than 32 bytes, it will automatically be padded with zero bytes on the left (the lower-indexed memory location) to fill the 32 bytes. These zeros will overwrite any other memory contents that were previously there.
You can overwrite data in memory if you write to the same location multiple times
Remember when we mentioned that the extra zeros mstore left-pads will overwrite any existing content within the 0th to 30th byte range? Let’s explore how that overwrite happens.
If we write 0xCC to the 0th byte using mstore8:
assembly {
mstore8(0, 0xCC)
}
we will now have 0xCC at the 0th position, while the rest of the memory remains unchanged, as illustrated in the diagram below.
Subsequently, if we store 0xFF using mstore(0, 0xFF) like so:
assembly {
mstore8(0, 0xCC)
mstore(0, 0xFF)
}
0xFF will overwrite the previously stored 0xCC at the 0th byte and fill the entire 32-byte slot (from the 0th to the 31st byte) with 0xFF.
Recall that mstore will write data to the entire 32-bytes slot and if we have fewer than 32 bytes, it will pad the remaining bytes with zeros like so:
The animation below shows how this overwrite happens:
This demonstrates that the 31 padded zeros of mstore actually alters the contents of memory.
How to remember the mstore padding
Instead of memorizing that mstore left-pads hex values with zero, we can consider that mstore(0, 0xff) is completely equivalent to mstore(0, 255).
In other words, mstore(0, 255) is saying “store the number 255 in the 32 bytes starting at byte 0, with byte 0 holding the most significant bytes.”
Since 255 is a “small number” compared to what a 32 byte number can hold (the maximum value of uint256), only the least significant bits will be used. The least significant bits are on the right, but the significant bits on the left are set to zero.
Similarly, the number 0xff00000000000000000000000000000000000000000000000000000000000000 is quite large.
In decimal, it is 115339776388732929035197660848497720713218148788040405586178452820382218977280. Therefore, it uses up the most significant bits, which are on the left.
Using the stored data to revert
We’ve seen how to store data in memory with mstore. During a revert, we need to store the error data in memory and return it as the revert error message.
The revert opcode takes two arguments: the starting memory slot and the total size of data we intend to return.
revert(startingMemorySlot, totalMemorySize)
From here onward we will show how to mimic the behavior of Solidity revert in the following situations:
Reverting without a reason string
Reverting with a custom error
Reverting with a custom error and reason string
1. Revert without a reason (message)
For a simple revert without a message, the assembly code revert(0,0) is equivalent to revert() in Solidity in behavior and gas cost. It does not return any data to the caller.
Under the hood, using revert(0,0), means “use no data” because the length of the data being referred to is zero. It is conventional to use memory location 0 as the starting point, but since we are returning nothing, we could do revert(1,0) and accomplish the same thing.
Here is a simple example of a revert without a reason using assembly:
contract ContractA {
function zero() external {
assembly {
revert(0,0) //<--- simple revert without reason
}
}
}
The screenshot below shows a low-level call from ContractA to ContractB and how the low-level call returned false because ContractB reverted, and no data is returned since we are using revert(0,0)
2a. Custom revert in assembly with no parameters
To illustrate how to simulate a custom error with no parameters using assembly, let’s use revert Unauthorized() as an example.
We will store the custom revert’s error function selector in a specific location in memory (0x00 by convention) and revert will point to that location in memory.
Here is the Solidity code we will use as an example:
When Solidity triggers a custom error, the return value is the ABI encoding of the custom error itself, which includes the function selector (the first four bytes of the keccak256 hash of the custom error signature).
Since we are using the custom error Unauthorized() as an example, we’ll first store the function selector (first four bytes of the keccak256 of Unauthorized() ) which will be 0x82b42900 padded with extra zeros to lengthen the value to 32 bytes to ensure that the actual four bytes of the function selector is written from byte 0 to byte 3 inclusive. Without this padding, the selector will not start at memory index 0.
We’ll now trigger the custom error with the revert statement below. Remember the template for the revert is revert(startingMemorySlot, totalMemorySize).
revert(0x00, 0x04)
The 0x00 is the memory location where we stored the error data while 0x04 (in hex) is the size of the error which is just 4 bytes. The entire revert code should now look like the code below:
The outcome is shown in the screenshot below. The only difference is in the gas cost. The screenshot shows that we saved 54 gas units by triggering revert via Assembly rather than in Solidity.
Also, in the code below, callContractB separately uses try/catch on customRevertWithAssembly and customRevertWithoutAssembly to parse the error, showing their behavior is the same.
An alternative method to store the selector when a custom error has no parameters and trigger revert
When a custom error has no parameters, the function selector is the only relevant data to return. In that case, we can store the function selector without manually adding the extra zeros and revert it from the specific memory region we stored.
For example, instead of padding with zeros like this:
We can write it without manually padding it with zeros :
assembly{
mstore(0x00,0x82b42900)
}
In memory, the zeros will be added in the left of the function selector from the 0th byte to the 27th byte, while the actual selector will be stored from the 28th byte to the 31st.
In other words 0x82b42900 gets expanded to 0000000000000000000000000000000000000000000000000000000082b42900 and stored in bytes 0 to 31 as shown below:
Since the function selector is now at the 28th byte (0x1c in hex), you can revert from this location instead of 0x00, as shown below:
If the custom error has parameters, we’ll need to ABI encode the arguments as well since it will be part of the revert return data. Assuming it has an address as an argument, we will store the argument in memory and point the revert arguments to both the selector and the address in memory.
As an example, let’s replicate the custom revert Unauthorized(address) in assembly.
The steps to replicate a custom revert with arguments in assembly is similar to the one without arguments, the only difference is that we’ll need to store the arguments (in this case, the address) as part of the return data. We’ll follow the steps below:
Store the function selector in memory for the custom error
Store the argument in memory after the selector
Trigger the revert by passing the starting memory location and the total size (4 bytes for the selector + argument size) to the revert function
1. Store the function selector in memory for the custom error
Just like in the custom error without parameters, we’ll need to first derive the function selector like so:
And the selector will be 0x8e4a23d6. We’ll now go ahead to store the selector starting at the 0x00 memory location with mstore as shown below:
assembly{
// Store the function selector at the memoryy location `0x00`
mstore(0x00, 0x8e4a23d600000000000000000000000000000000000000000000000000000000)
}
2. Store the argument in memory after the selector
After writing the function selector to memory starting from the 0th byte, we’ll now store the address from the 4th byte as shown below:
assembly{
//...// Store the address// *Note that `caller()` in assembly is the same as `msg.sender` in Solidity.*
mstore(0x04, caller())
}
The function caller() will return the address upcasted to 32 bytes. So if the original address was 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4caller() will return 0x00000000000000000000000005B38Da6a701c568545dCfcB03FcB875f56beddC4 and this is the 32-byte value mstore will place in memory starting at byte 4.
3. Trigger the revert
And finally, we can now trigger the revert with the starting memory location and the total size the data we’ve stored so far (4 bytes for the selector + 32 bytes for the address) occupies 36 bytes (hex 0x24) as arguments as shown below:
function customRevertWithAssembly() publicpure {
assembly {
//...// 4 bytes for selector + 32 bytes for the addressrevert(0x00, 0x24)
}
}
And this is how the entire code for the custom revert with parameters in assembly will look like:
// SPDX-License-Identifier: GPL-3.0pragma solidity 0.8.27;
contract A {
function customRevertWithAssembly() public view {
assembly {
// Store the function selector at the memory location `0x00`
mstore(0x00, 0x8e4a23d600000000000000000000000000000000000000000000000000000000)
// Store the address// N*ote that `caller()` in assembly is the same as `msg.sender` in Solidity.*
mstore(0x04, caller())
// 4 bytes for selector + 32 bytes for the addressrevert(0x00, 0x24)
}
}
}
This is how Solidity will store the revert data in memory and the result of the revert data will be returned eventually.
3. Revert with a reason in assembly
When a revert with a reason string such as revert("reason") is triggered, the reverting contract returns the ABI encoding of Error(string), along with the string argument. This is the same as how require with a reason work in Solidity.
To simulate the revert with a reason string in assembly, we need to ABI encode the same function and the string argument in memory.
Let’s use revert(“Unauthorized”) as an example:
contract A {
function revertWithAString() externalpure {
revert("Unauthorized");
}
}
If we trigger the revert("Unauthorized"); function in the contract above, the result will look like the example below.
In this section, we’ll replicate the revert with a string behavior in Solidity using assembly by following the steps below:
Store the function selector for Error(string) in memory
Store the offset to the error message string
Store the length of the error message string
Store the actual error message
Trigger the revert
Below is a quick representation of the above steps in assembly code:
contract RevertErrorExample {
function revertWithAssembly() publicpure {
assembly {
// store the selector
mstore(
0x00,
0x08c379a000000000000000000000000000000000000000000000000000000000
)
mstore(0x04, 0x20) // store the offset
mstore(0x24, 0xc) // store the length of the string
mstore(
0x44,
0x556E617574686F72697A65640000000000000000000000000000000000000000
) // store the actual datarevert(0x00, 0x64) // trigger a revert
}
}
}
Let’s examine the assembly block line-by-line.
1. Store the function selector for Error(string)
We first store the function selector at the starting memory location (0x00):
assembly {
mstore(0x00, 0x08c379a000000000000000000000000000000000000000000000000000000000) //Store the function selector
}
You can derive the function selector (the first 4 bytes of keccak256(Error(string)) with abi.encodeWithSignature("Error(string)") and then cast it to a 32-byte word with bytes32 type like so:
The first 4 bytes (0x08c379a0) is the selector padded with zeros to make up for the 32-bytes requirement.
2. Store the offset to the error message string
The next part of the string error we store is the offset. The offset is 32 bytes (0x20 in hex).
mstore(0x04, 0x20) // 4 is 0x04 in hex
Remember, we mentioned that it’s possible to overwrite memory if two memory locations overlap, right? Initially, the function selector was stored starting at the 0th byte as a 32-byte word. Now, we are storing the offset starting at the 4th byte.
This means that the remaining data from the function selector (the padded zeros in this case) will be replaced, starting from the 4th byte as shown in the diagram below:
3. Store the length of the error message string
The third part of the string we need to store is the length of the string data. Recall that we stored the function selector at the 0x00 location and it took up 4 bytes. Then the next memory location was the offset at memory location 0x04 which took up 32 bytes.
That means 4 bytes selector + 32 bytes offset tells us that the next memory slot should be at 36 bytes which is where we’ll store the length of the string.
The length of the string Unauthorized is 12 (0xc) bytes.
mstore(0x24, 0xc) // 36 is 0x24 in hex
4. Store the actual error message string
The actual string Unauthorized is stored starting at 68 (0x44) bytes from the beginning which corresponds to the 4 bytes for the selector + 32 bytes offset + 32 bytes for the length. So far, we have written 100 bytes of data.
mstore(0x44, "Unauthorized") //68 is 0x44 in hex// We can store Unathorized as hex as well. Unauthorized in hex is ⤵️// 0x556E617574686F72697A65640000000000000000000000000000000000000000
5. Trigger the revert:
The revert operation uses the starting memory location and the total size of the data to trigger the revert.
The total size will be 100 (0x64) bytes by adding the 4 bytes for the selector, 32 bytes for the offset, 32 bytes for the length of the string, and 32 bytes for the string content “Unauthorized”.
Remember the template for the revert in assembly:
revert(StartingMemorySlot, totalMemorySize)
Here is how we’ll trigger the revert:
revert(0x00, 0x64) // 100 is 0x64 in hex
So, the revert will return the following data with exactly 100 bytes when triggered:
Even though the string Unauthorized does not use up the full 32 bytes, the receiver will know to only read 12 bytes of data due to the length parameter 0x0c.
If we put all the steps together, we’ll arrive at this code:
// SPDX-License-Identifier: GPL-3.0pragma solidity >=0.7.0 <0.9.0;
contract ContractA {
function revertWithAssembly() externalpure {
assembly {
mstore(
0x00,
0x08c379a000000000000000000000000000000000000000000000000000000000
) // store the selector
mstore(0x04, 0x20) // store the offset
mstore(0x24, 0xc) // store the length of the string
mstore(
0x44,
0x556E617574686F72697A65640000000000000000000000000000000000000000
) // store the actual datarevert(0x00, 0x64) // trigger a revert
}
}
}
}
Here is a screenshot showing the output of the revert when you call the revertWithAssembly() the function. The result is the same as what we saw when we triggered revert(“Unauthorized”) in Solidity.
However, the difference is in the amount of gas they both consume. Run the reverts in the following contracts to see the difference in gas cost. Below is the code we use to test the gas costs:
// SPDX-License-Identifier: GPL-3.0pragma solidity >=0.7.0 <0.9.0;
contract ContractA {
function revertWithAssembly() externalpure {
assembly {
mstore(
0x00,
0x08c379a000000000000000000000000000000000000000000000000000000000
) // store the selector
mstore(0x04, 0x20) // store the offset
mstore(0x24, 0xc) // store the length of the string
mstore(
0x44,
0x556E617574686F72697A65640000000000000000000000000000000000000000
) // store the actual datarevert(0x00, 0x64) // trigger a revert
}
}
}
contract ContractB {
function revertWithoutAssembly() externalpure {
revert("Unauthorized");
}
}
The illustration below shows the difference in gas cost for the revertWithAssembly function and the revertWithoutAssembly.
From the above test, we saved 273 gas as the revert without assembly cost 428 gas while the revert with assembly cost 155 gas. The difference is 273.
To further verify that the error was properly formed, we can try to catch the error in a try/catch block as shown in the screenshot below:
From the above screenshot, we can see that the error was caught in the Errorcatch block as expected and the reason was printed Unauthorized.
Conclusion
In this guide, we have learned how revert works by implementing Solidity reverts manually using inline assembly.
We covered:
how mstore and mstore8 works
how to mimic the following kind of reverts:
reverts without reasons
custom error reverts
and reverts with reason
We also saw how we could save some gas by using revert via assembly. I encourage you to experiment it yourself as that’s the best way to fully understand how it all comes together.
Happy coding
This article was written by Eze Sunday in collaboration with RareSkills.
Computing the Current Tick Given sqrtPriceX96 In the previous chapters, we saw that the protocol stores the square root of the price instead of the price itself. Therefore, it is necessary to relate ticks to the square root of the price in the fixed-point Q64.96 format. In this chapter, we will explore how to convert […]
Uniswap V3 Factory and the Relationship Between Tick Spacing and Fees In early chapters, we introduced the concept of ticks, which discretize the price curve. A tick is a price defined by the formula , where is named tick index. Tick indexes are integers within the range , resulting in 1,774,545 ticks along the […]
ZK Proof of Selection Sort Most computations of interest are generally “stateful” — that is, they need to go through a series of steps to produce the final result. Sometimes, we do not need to show we executed the computation but only show the result. For example, if A is a list, we can prove […]
How a ZKVM Works A Zero-Knowledge Virtual Machine (ZKVM) is a virtual machine that can create a ZK-proof that verifies it executed a set of machine instructions correctly. This allows us to take a program (a set of opcodes), a virtual machine specification (how the virtual machine behaves, what opcodes it uses, etc), and prove […]
team-rareskills
4 weeks ago
12 mins read
Opportunities
Looking for an audit?
Leverage our extensive network of top security specialists.