How big is Solidity custom error messages overhead?
The latest Solidity version (v0.5.0) has 3 language constructions to halt an execution in case of error: assert(), revert() and require(). Besides execution halting, all these constructions revert all changes made during the call. Lets briefly review their differences:
assert() eats all of the provided gas, gives you no option to specify the error message, uses 0xfe opcode (INVALID) to halt the execution.
revert(msg) returns all the remaining gas, provides you an option to specify an error message, uses 0xfd opcode (REVERT) to halt the execution.
require(condition, msg) which is a syntax sugar for the previous one. It executes revert(msg) in case if condition execution result is true.
In this topic, we won’t discuss assert(), since it doesn’t have the error description string functionality. We are interested only in the last two ones.
Solidity versions before v0.4.22 in a case when execution was halted using either revert() or require() constructions, we were left with just REVERT message and no additional information about a reason of the exception — neither a line number nor a file is specified. Starting from Solidity 0.4.22 it is possible to provide a reason string for revert() and require() functions (but not for assert()). If you make this reason string a unique, you could easily find the place caused this revert in your source code. This amazing feature helps a lot in contract debugging and saves a lot of time. But this convenience comes with a price.
The reasonable question should be — where do these strings are stored? The answer is simple: in the contracts bytecode, along with contract functions, constants, and other logic. When a revert() condition is reached, the error message is pushed into memory in a similar way if you were returning it. Roughly speaking, the difference is that you have REVERT opcode instead of RETURN at the end. But in both cases, there is a code which pushes abi-encoded string into memory to make it available being read by a caller.
The above means that there are at least two metrics affected by using error strings — gas cost and contract size.
The first metric is a gas cost. Definitely the most discussed topic now from a perspective of contract optimization. But pushing a few values into a memory should not cost too much. We’ll measure the values below.
Another, not so popular metric — a contract size. EIP-170 introduced a limit of 0x6000 (24 576) bytes for a single contract. This limit is applicable to the main net. Other networks could override this value, but most of them prefer to adhere to this approach.
To benchmark different cases let’s use the following contracts:
To measure the metrics I used Remix and Solidity v0.4.25 compiler with an enabled optimizer. Here are the results:
As you could see above, there is a 32-byte threshold when both contract size and gas amount are increasing. The case when we don’t have an error description at all is the cheapest one. The results for contracts 2 and 3 shows us that both 1-byte and 32-bytes strings consume the same amount of resources. So there is no sense of keeping the message short as possible. If you have already decided to use error messages, you have 32 ASCII symbols (or 16 for UTF8) for before the next threshold. So use them to make your error message meaningful and descriptive enough.
As a reminder, we have 24 576 bytes limit for a contract size and about 8 000 000 gas limit for a transaction. In the perspective of gas saving, I hardly imagine that you could be stuck into a block limit by trying to return a big enough error string.
But if you have a lot of error messages you are more likely to have problems with the contract deployment. 50 descriptive enough stings with 65 symbols each will take 9 450 of 24 576 bytes available to you which is more than 1/3 of all space available for storing contract bytecode. Bear in mind that in addition to error messages, a contract also contains functions, static data, and another payload 🤓. This means that in case of lack of space available for storing contract bytecode, cutting off revert strings could be one of the solutions.
Another interesting thing I found is that Solidity optimizer unifies revert code for revert()/require() constructions with the same messages by introducing a helper function which is called from all these steps. This way, if you are using the same message in multiple locations of a single file, you won’t see bytecode growth starting from the second construction, as it could be if you were using different error messages. But beware that it may confuse you while code debugging.
To wrap up the above, I want to say, that using descriptive error messages is definitely a good pattern unless you are on the edge of contract byte code limit. But if you are, you could save some space by removing this error messages, since it would cost you about 100 bytes per each.