1. Testing Arithmetic Operation and Conversion
Mathematical operations on different programming languages and platforms may work differently. The arithmetic operations done in the smart contract should be able to safely handle the whole range of possible values.
This testing will cover the arithmetic operations and type conversions of smart contracts. The native data types of numbers in EVM are signed and unsigned integers, which do not natively support floating number data types. Therefore, these concerns should be checked thoroughly when handling the mathematical operations.
1.1. Integer Overflow and Underflow
Integer overflow occurs when the result of an arithmetic operation exceeds the maximum value of the data type. The resulted data type will be determined by the data type of the operands. When the size of the result value is larger than the size of the determined data type, the extra bits will be discarded, and the stored value may not be the same as the actual value.
For example, if an unsigned 8-bit integer variable holds the maximum value of 255
(1111 1111
in binary) and is incremented by 1
, the resulting value of 256
(1 0000 0000
in binary) cannot be stored in the same data type. As a result, only the last 8 bits of the value are stored, which is 0 (0000 0000
in binary).
On the other hand, when a value is decreased below the minimum limit by being subtracted with a positive integer, the result will be wrapped around, which is also known as integer underflow.
In Solidity's complier version prior 0.8.0, there is no builtin overflow and underflow protection in both the contract compilation and runtime. Thus, integer overflow and integer underflow can occur whenever arithmetic operations have happened.
Testing
Check the Solidity complier version of the contract. If the version of the Solidity compiler version is below 0.8.0, you need to verify that all arithmetic operations are not affected by overflows and underflows.
This can be done by looking at each arithmetic operation and examining whether the range of the result exceeds the range of the declared data type or not, for example:
In the snippet code above, if the _amount
is higher than the balance of the sender (msg.sender
), the result will be wrapped around from the underflow, allowing the condition in the require statement to be fulfilled. This can cause the balance of the sender to be inflated. Also, if the balance of the receiver is added with _amount
and exceeds the limit of uint8
(2^8 - 1), the balance will overflow and wrap around to 0.
If the version of the Solidity compiler version is 0.8.0 or higher, the overflow and underflow will only occur under unchecked{}
block. Thus, there are two points for verifying:
1.1.1. Solidity compiler version 0.8.0 and higher
Verify that the overflows and underflows inside the unchecked{}
blocks are proper expected behaviors.
1.1.2. Solidity compiler version 0.8.0 and below
Verify that the overflows and underflows that could occur are prevented if they break the contract's design or functionalities.
1.2. Precision Loss
The numeric data types that EVM fully natively supports are signed and unsigned integers with a size up to 256 bits. EVM also has fixed point data types for floating, but they are currently not fully supported yet (as of writing, the Solidity complier version is 0.8.20). To represent a floating point number, we commonly separate the number of decimals and treat those digits as a decimal part of the value. Therefore, the defined number of decimals is a fixed number. It cannot distinguish a value that is less than that decimal point.
For example, we declare a uint256
variable and define its decimal to be 9
. It means that the last 9
least significant digits represent the decimal of the variable, and the value from the variable will be treated as follows:
When an integer is divided, the remainder will not continue being divined to produce a decimal part of the result, since, it is an integer division. A precision loss will occur if the remainder of the division is not zero.
A precision loss is inevitable by the nature of EVM, but the impacts depend on the difference between the magnitude of the loss value and the dividend. So, a precision loss problem will be insignificant if the dividend is sufficiently larger than the divisor.
Testing
A precision loss can occur when there is division in the operation. There are two cases for testing the division:
1.2.1. The rounding down of the division
The result of division will always be rounded down, and it will be zero if the divisor is larger than the dividend. You must verify the impact of the result of the rounding down.
The test can be done by checking the division operations in the contract. If the divisor and the dividend are independent values, the divisor can be greater than the dividend and the result will be zero.
For example, the function below, it take the amount
of the depositToken
and mint a token (shareToMint
) according to the proportion of the amount
and the balance of the deposited token in the contract.
It is possible to use the rounding down to perform the attack as follows:
Attacker deposits 1
wei
of token to the contract as the first depositorAttacker attacker transfers 100
ether
of tokens to the contract.Victim deposits 200
ether
of tokens into the contract. Due to the rounding down, the victim gets only 1 share (200ether
/ 101ether
).Attacker attacker withdraws 1 share to get 150
ether
, resulting in a profit of 50ether
1.2.2. The order of division and multiplication
To have the least impact from precision loss, it is better to have a larger dividend before it is divided. Since division and multiplication are commutative, calculating the multiplication part first and then doing the division will result in less impact from a precision loss.
The test can be done by checking that the division operation that results in truncation of precision is not done before multiplication.
For example, using the source code below:
If the amount deposited is 87654321, the depositFee
should be equal to:
However, as the result is rounded down during the division, the result from the Solidity source code above will be equal to:
1.3. Type conversion
The data in EVM is stored in binary format. The data type of the states on the contract level is just how the contract perceives the stored data. The same data in storage or memory can represent different values depending on the data type that the contract will interpret.
At the contract level, the data type of the variables must conform with the operators' accepted data type (as regards the Solidity complier version 0.8.20
). The data type of a variable can be cast to another type explicitly; however, if the destination type cannot hold the values of the original variable, unexpected values can be yielded from the truncation or padding.
In this testing, we will focus only on the conversion of these types of variables: intXX
, uintXX
, address
, and bytesXX
. Since they occupy one slot of storage and can be freely converted around. There is a rule about the type conversion in solidity: you cannot change size, sign (negativity), and type in one conversion. So, there are three cases to consider when checking the conversions:
the change of size
the change of type
the change of sign
Testing
1.3.1. The change of size (Same type with different size conversion)
It is a conversion that can occur on a data type that has many sizes, i.e., intXX
, uintXX
, and bytesXX
. Converting a smaller data type into a larger one has no issues. But, in the case of converting from a larger data type into a smaller one, the value can be altered; you should check thoroughly that the result is intended. The types uintXX
and bytesXX
have different ways of padding and truncating the data. The user must beware when the conversion takes many steps, a different intermediate conversion could lead to a different conversion outcome.
The test can be done by checking the explicit conversions in the contracts. When there are conversions that reduce the size, a validation for the converted value must be enforced.
For example, a contract below locks a token and packs the token address and the amount into a struct. But the amount, with size 256 bits, has been converted into 96 bits. The stored amount in the contract will be less than the transferred amount if the transferred amount is greater than the maximum value of uint96
.
1.3.2. The change of type (Different types with the same size conversion)
It is a conversion that changes the type but still keeps the size. It normally happens between uintXX
, bytesXX
, and address
. The data type of address
is special. The address
type can be directly converted to uint160
and bytes20
. The underlying value (the binary value) will always be the same in the same size conversion case.
For example, the conversions below are coversions with the same size across the address, uint, and bytes datatypes. The results of the conversion are the same for the same datatype, including the conversion that increases the size of the datatype.
Some values will differ when we covert the value down with a changing of type.
1.3.3. The change of sign (Different sign conversion)
The intXX
type is the only signed data type. Other data types in Solidity are considered unsigned data types. Therefore, the intXX
type can only be converted into the uintXX
type.
The test can be done by checking the explicit conversions in the contracts that converting from and into int
datatype.
Checklist
Values should be checked before performing arithmetic operations to prevent overflows and underflows.
Integer division should not be done before multiplication to prevent loss of precision.
Explicit conversion of types should be checked to prevent unexpected results.
Last updated