✅RACE #13 - ERC20 with Callback
Last updated
Last updated
Note: All 8 questions in this RACE are based on the below contract. This is the same contract you will see for all the 8 questions in this RACE. The question is below the shown contract.
In transferFrom(), unchecked is not used in the allowance subtraction:
In transfer() and transferFrom(), the use of unchecked would not be desired:
Comment:
Originally, the correct answer was intended to be A with the following explanation from Leo: "Unchecked would be always desired if this operation can never overflow, and never desired if it can easily overflow. Neither case is true. The number of decimals can influence that. The more decimals the token uses, the bigger one's balance is, in terms of tokens. Depending on how large decimals is, it can lead to the case where overflow is possible in a legit use case. In this case, unchecked could lead to bugs."
This assumed though, that the operation can overflow, which it actually can't in the context of this contract since, as mentioned, by the code-comment "Cannot overflow because the sum of all user balances can't exceed the max uint256 value." thanks to the _mint() function's totalSupply increase not being in an unchecked block.
Although the use of large decimals doesn't negatively impact the token's own logic, it should still be mentioned that it might cause issues for other contracts that would like to integrate the tokens. An example for this would be the multiplication of two user's balances where, as per best practice, multiplication would happen before division (to avoid loss of precision) and might cause an overflow.
In name() and symbol(), the returned values are incorrect because:
Comment:
The first MSTORE writes a static 0x20
(32 in decimal) to the first memory slot. This is done since the RETURNed value is expected to be ABI-encoded and the encoding of dynamic types such as string is an offset that points to where the actual string in the return data starts. Since there's no other data being returned, it simply points to the next 32-byte slot starting at address 0x20
.
The second MSTORE actually writes two things at the same time: The string length and the actual string contents. Strings always consist of these two parts and it's important to remember that the length is right-aligned (like all numbers in memory) in the first slot, while the string's content is left-aligned (like all strings and dynamic byte-arrays in memory) in the following slots.
Also, remember that MSTORE always writes 32 bytes of data starting at the specified address even when the data you specified is shorter than 32 bytes. In such cases, the data will be left-padded to automatically right-align it like a number. Both functions make use of this fact by not starting at 0x20
but instead increasing the address by the length of the string. That way the first byte of the string in the code will end up at the last byte of the slot while the following bytes are moved to the beginning of the next slot.
While this seems like an elegant approach, the problem now is that only the first few bytes (the string contents) of the third slot have been written to. All of the following bytes are simply assumed to be zero-values although they might contain junk which could cause issues for the receiver of the returned data. In this specific case, the code accesses the memory area where Solidity stores the "free memory pointer". This value is returned as part of the string, which is basically junk.
Finally, the RETURN operation in inline assembly not only leaves the function, but stops the execution of the transaction. It's is told to return 0x60
(3 * 32) bytes starting at offset 0x0
, effectively returning all of the memory slots that had been written to:
To correct name(), one could make the following change(s):
Comment:
Answer A moves the memory used for the return value by one 32-byte slot "to the right". This way the string won't end up sharing its space with where Solidity stores the "free memory pointer" and no junk will be returned anymore, at least in this specific case. It still clashes with other reserved memory areas of Solidity, but since they are zero in this case it doesn't matter. The fact that reserved memory areas are used also is no problem as long as control is never returned back to Solidity, as is the case here thanks to the use of RETURN.
Answers B and C are basically equivalent and represent the usual ways one would do it in Solidity.
The concern(s) with the check in notify() is/are:
Comment:
A and B are simply not true, as fallback and receive don't have selectors. Rather, they are called when no function with the requested selector was implemented in the called contract.
Answer C is regarding the possibility that, although unlikely, a normal external function could end up having 0x00000000
as function selector. In such a case, Solidity will revert (thinking that the variable was not properly initialized) when attempting to call said callback. Therefore a valid callback would not be called.
The concern(s) with the call in notify() is/are:
Potential change(s) to notify() to mitigate further security concern(s) is/are:
How can the contract be exploited for loss-of-funds via notify callback reentrancy?