Solidity Quirks: Literals and Mobile Types
Have you or anyone you know recently written Solidity functions involving literals? It's time to go back and take another look.
Today, as I was researching about the Solidity compiler, I came across an interesting Twitter post by Marco from PaladinSec. It was about calculations involving literals in Solidity and accompanying it was the question: "When do these functions revert?".
The accepted answers on the post, while correct, did not explain why what happened, happened. The problem was not immediately clear to me so I decided to look deeper into it. My investigation turned out to be enlightening and fruitful. I came to know of some interesting Solidity quirks when dealing with calculations involving literals, knowledge of which, I believe, is crucial when writing or auditing smart contracts. What follows is a detailed explanation of mobile types and calculations involving literals in Solidity.
Understanding Literals and Mobile Types
One thing to keep in mind when dealing with literals in Solidity is mobile types. The Solidity docs mention this phenomenon but don't go in further detail:
In case one of the operands is a literal number it is first converted to its “mobile type”, which is the smallest type that can hold the value (unsigned types of the same bit-width are considered “smaller” than the signed types). If both are literal numbers, the operation is computed with effectively unlimited precision in that the expression is evaluated to whatever precision is necessary so that none is lost when the result is used with a non-literal type.
To elaborate, whenever we use a literal in a calculation e.g., 1 days
(which is (1*24*60*60)
i.e., 1 day in seconds), it first gets converted to its mobile type before the operation is performed. Mobile type for any literal is the smallest type that literal can be stored in. In case of an integer literal, it is the smallest uint type that can hold it.
Let's look at an example where calculation involving literals is being performed:
function test(uint8 num) public pure returns (uint32) {
return num * 1 days;
}
So, given a function call with an input of type uint8
, 1 days
, 1*24*60*60
which is 86,400
will first get casted into its mobile type.
Let's see what is the smallest uint type that can hold 86,400
(the mobile type) . We can use Chisel (part of the Foundry toolkit) for this.
type(uint8).max
= 255type(uint16).max
= 65535type(uint24).max
= 16777215
From the max values supported by each uint type, we can see that uint8
and uint16
would not be used as the mobile type for our literal 86,400
, since the max value as supported by uint8
is 255
and by uint16
is 65,535
. However, uint24
appears to be large enough to house our value. Thus, the compiler performs a bitwise AND
operation on the max value supported by uint24
0xffffff
and our literal value 0x15180 (86,400 in hexadecimal)
, thereby converting the value into a uint24
one (this operation pads zeros to the value to make it the same size as a uint24
value).
The literal is now of type uint24
and the operation continues; the uint8
input will get multiplied by uint24(85400)
. To carry out this operation, the compiler performs an implicit upcast of the smaller operand to make them both of the same type (whichever is larger); the uint8
input is converted to uint24
. The product of the operation is also then stored in a variable of this common type (uint24
, common to both operands).
The result, 950400
, is then upcasted into the return type, uint32
, and returned to the caller.
Testing Our Understanding
We now know about the implicit type conversions that take place when dealing with literals. Let's test our understanding:
Think about when the test
function would revert. We know that the input is of type uint8
, which gets multiplied by the literal of type uint24
(after conversion to the mobile type), the result is of type uint24,
which is cast into uint32
and returned. So, whatever input value makes the product of the multiplication larger than the max value supported by the mobile type (uint24
), will make the function call revert.
1 days
, as we know, is equal to 86,400
, for which a uint24
mobile type is used. Now, what uint8
value, when multiplied by 86,400
will make the result go above 16777215
, the max uint24
value? Performing 16777215/86400
we can see the result is 194.1807291667
. So, multiplying 194
by 86400
will give us 16,761,600
which is closest we can get to the max value without overflowing the uint24
container. Thus, an input of 195
would cause an overflow when we call our function, notwithstanding the fact that 195
is a valid uint8
value and the return type can also fit the product of 195
and 864000
. This, as we now now, is because of the intermediate type conversion to the mobile type of uint24
.
Back to the question
So when do the functions mentioned in the twitter post revert? We now have the answer: they revert whenever the input is such that result of the calculation (multiplication in this case) causes the result to exceed the max value supported by the mobile type.
In case of toHours
, let's see what the mobile type for the literal 24
would be. 24
can fit into the smallest uint type available, which is uint8
. The result of 24 * _days
would also be uint8
since both operands are of the same type. As such, any value which results in the product of the calculation to exceed type(uint8).max
would cause a call to the function to revert. In this case, it would be any uint8
value above 10, since type(uint8).max/24
= 10.625
.
In case of toSeconds
, a uint8 value is multiplied by a literal 1 days
. The mobile type for this literal would be uint24 since 1 days
equals 86,400
which is larger than a uint16 but can fit in a uint24. _days
is upcast to uint24
and the two operands are multiplied. The result is also a uint24
value. Let's check what uint8 value, when multiplied by 1 days
will overflow the uint24 container. 16777215/255
= 194.1807291667
. So, any input value larger than 194
would cause the function to revert.
Conclusion
In this article, we learned about an interesting but important Solidity quirk; namely literals and how they are converted to a mobile type during calculations. We also looked at several examples and provided an in-depth explanation of this phenomenon. At the end, we were able to answer how and in what circumstances the example functions would revert.
Further Reading
Marco's twitter post.
GitHub issue providing a good summary of the problem
Solidity Docs describing mobile types.
Learned something new? Consider subscribing. I will be posting in-depth content relevant to blockchain security and smart contract auditing.