Platform: Code4rena
Start Date: 04/01/2022
Pot Size: $25,000 USDC
Total HM: 3
Participants: 40
Period: 3 days
Judge: Ivo Georgiev
Total Solo HM: 1
Id: 75
League: ETH
Rank: 14/40
Findings: 2
Award: $340.25
🌟 Selected for report: 5
🚀 Solo Findings: 0
13.1525 USDC - $13.15
Dravee
Waste of gas due to unnecessary underflow checks
On XDEFIDistribution.sol:120
and XDEFIDistribution.sol:175
, you can find the following substraction:
uint256 withdrawAmount = amountUnlocked_ - lockAmount_;
However, as the Solidity version is 0.8.10, default overflow and underflow checks are made, which cost some gas.
You can save this gas with the unchecked
keyword to bypass these checks as 5 lines above (L115 and L170), a require
statement already checks that lockAmount_ <= amountUnlocked_
.
Therefore, no underflow is possible.
VS Code
Use the "unchecked" keyword
#0 - deluca-mike
2022-01-05T08:19:54Z
Yep, while I originally did not want to messy the code with unchecked
math, enough people have mentioned it, and I've now down enough analysis to just go ahead and do it.
#1 - deluca-mike
2022-01-14T03:25:35Z
In release candidate contacts, done in _relock
.
🌟 Selected for report: agusduha
Also found by: 0xsanson, Czar102, Dravee, GiveMeTestEther, WatchPug, p4st13r4, saian, sirhashalot
4.7941 USDC - $4.79
Dravee
The compiler does not reserve a storage slot for constant variables. Therefore, a state variable that is never changed should be declared constant to save gas.
Instances include:
XDEFIDistribution.sol:14: uint88 internal MAX_TOTAL_XDEFI_SUPPLY = uint88(240_000_000_000_000_000_000_000_000);
Slither (Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#state-variables-that-could-be-declared-constant)
Add the constant attribute to state variables that never change.
#0 - deluca-mike
2022-01-05T08:20:47Z
True, and it would be, but we are going to be removing it altogether. Still a good and valid catch.
#1 - deluca-mike
2022-01-09T10:28:47Z
Duplicate #36
13.1525 USDC - $13.15
Dravee
Public functions that are never called by the contract should be declared external to save gas.
This function is never called by the contract:
XDEFIDistribution.withdrawableOf(uint256) (contracts/XDEFIDistribution.sol#156-159)
Slither
Change function visibility from public
to external
#0 - deluca-mike
2022-01-05T08:14:15Z
Yep, good catch.
#1 - deluca-mike
2022-01-09T10:33:06Z
Duplicate #6
🌟 Selected for report: Dravee
Also found by: Czar102, TomFrenchBlockchain, defsec
18.2673 USDC - $18.27
Dravee
On external functions, when using the memory
keyword with a function argument, what's happening is that a memory
acts as an intermediate.
Reading directly from calldata
using calldataload
instead of going via memory
saves the gas from the intermediate memory operations that carry the values.
As an extract from https://ethereum.stackexchange.com/questions/74442/when-should-i-use-calldata-and-when-should-i-use-memory :
memory
andcalldata
(as well asstorage
) are keywords that define the data area where a variable is stored. To answer your question directly,memory
should be used when declaring variables (both function parameters as well as inside the logic of a function) that you want stored in memory (temporary), andcalldata
must be used when declaring an external function's dynamic parameters. The easiest way to think about the difference is thatcalldata
is a non-modifiable, non-persistent area where function arguments are stored, and behaves mostly like memory.
interfaces\IXDEFIDistribution.sol:55: function baseURI() external view returns (string memory baseURI_); interfaces\IXDEFIDistribution.sol:74: function setBaseURI(string memory baseURI_) external; interfaces\IXDEFIDistribution.sol:77: function setLockPeriods(uint256[] memory durations_, uint8[] memory multipliers) external; interfaces\IXDEFIDistribution.sol:106: function relockBatch(uint256[] memory tokenIds_, uint256 lockAmount_, uint256 duration_, address destination_) external returns (uint256 amountUnlocked_, uint256 newTokenId_); interfaces\IXDEFIDistribution.sol:109: function unlockBatch(uint256[] memory tokenIds_, address destination_) external returns (uint256 amountUnlocked_); interfaces\IXDEFIDistribution.sol:119: function merge(uint256[] memory tokenIds_, address destination_) external returns (uint256 tokenId_); interfaces\IXDEFIDistribution.sol:125: function tokenURI(uint256 tokenId_) external view returns (string memory tokenURI_); XDEFIDistribution.sol:73: function setBaseURI(string memory baseURI_) external onlyOwner { XDEFIDistribution.sol:77: function setLockPeriods(uint256[] memory durations_, uint8[] memory multipliers) external onlyOwner { XDEFIDistribution.sol:165: function relockBatch(uint256[] memory tokenIds_, uint256 lockAmount_, uint256 duration_, address destination_) external noReenter returns (uint256 amountUnlocked_, uint256 newTokenId_) { XDEFIDistribution.sol:186: function unlockBatch(uint256[] memory tokenIds_, address destination_) external noReenter returns (uint256 amountUnlocked_) { XDEFIDistribution.sol:205: function merge(uint256[] memory tokenIds_, address destination_) external returns (uint256 tokenId_) {
VS Code
Use calldata
instead of memory
for external functions where the function argument is read-only.
#0 - deluca-mike
2022-01-05T08:47:54Z
Yup, good catch. I'm usually a stickler for this, and I can't believe I didn't do it already.
#1 - deluca-mike
2022-01-13T21:49:44Z
Fixed in release candidate:
setBaseURI
interface and implementationsetLockPeriods
interface and implementationrelockBatch
interface and implementationunlockBatch
interface and implementationmerge
interface and implementation#2 - Ivshti
2022-01-16T05:53:51Z
valid finding
27.0627 USDC - $27.06
Dravee
!= 0
costs less gas compared to > 0
for unsigned integer
> 0
is used in the following location(s):
XDEFIDistribution.sol:144: require(totalUnitsCached > uint256(0), "NO_UNIT_SUPPLY");
VS Code
Change > 0
with != 0
.
#0 - deluca-mike
2022-01-06T08:16:46Z
This is not always the case, and I tested, and this does not matter in this case. In any case, this is a valid suggestion, and we will consider it throughout the code when we change all the require
s to if-revert
s, and thus the inequalities change.
#1 - deluca-mike
2022-01-09T10:42:22Z
Duplicate #88
18.2673 USDC - $18.27
Dravee
In Solidity 0.8+, there's a default overflow check on unsigned integers. It's possible to uncheck this in for-loops and save 77 gas per iteration, but at the cost of some code readability, as this uncheck cannot be made inline.
Instances include:
XDEFIDistribution.sol:80: for (uint256 i; i < count; ++i) { XDEFIDistribution.sol:212: for (uint256 i; i < count; ++i) { XDEFIDistribution.sol:325: for (uint256 i; i < count; ++i) {
VS Code
The code would go from:
for (uint256 i; i < numIterations; ++i) { // ... }
to:
for (uint256 i; i < numIterations;) { // ... unchecked { ++i; } }
While the risk are overflow is inexistant for a uint256 i
, you might want to keep in mind to manually check for bounds once before the for-loop if i
is smaller than a uint256
(such as uint8
, but it's never recommended and it's not the case in this project).
#0 - deluca-mike
2022-01-05T09:09:45Z
Will do. Good catch.
#1 - deluca-mike
2022-01-09T10:22:26Z
Duplicate #9
🌟 Selected for report: Dravee
Also found by: GiveMeTestEther
45.1044 USDC - $45.10
Dravee
Custom errors from Solidity 0.8.4 are cheaper than revert strings.
Source: https://blog.soliditylang.org/2021/04/21/custom-errors/:
Starting from Solidity v0.8.4, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. Until now, you could already use strings to give more information about failures (e.g., revert("Insufficient funds.");
), but they are rather expensive, especially when it comes to deploy cost, and it is difficult to use dynamic information in them.
Custom errors are defined using the error
statement, which can be used inside and outside of contracts (including interfaces and libraries).
Instances include:
XDEFIDistribution.sol:40: require((XDEFI = XDEFI_) != address(0), "INVALID_TOKEN"); XDEFIDistribution.sol:47: require(owner == msg.sender, "NOT_OWNER"); XDEFIDistribution.sol:52: require(_locked == 0, "LOCKED"); XDEFIDistribution.sol:63: require(pendingOwner == msg.sender, "NOT_PENDING_OWNER"); XDEFIDistribution.sol:82: require(duration <= uint256(18250 days), "INVALID_DURATION"); XDEFIDistribution.sol:115: require(lockAmount_ <= amountUnlocked_, "INSUFFICIENT_AMOUNT_UNLOCKED"); XDEFIDistribution.sol:145: require(totalUnitsCached > uint256(0), "NO_UNIT_SUPPLY"); XDEFIDistribution.sol:170: require(lockAmount_ <= amountUnlocked_, "INSUFFICIENT_AMOUNT_UNLOCKED"); XDEFIDistribution.sol:207: require(count > uint256(1), "MIN_2_TO_MERGE"); XDEFIDistribution.sol:214: require(ownerOf(tokenId) == msg.sender, "NOT_OWNER"); XDEFIDistribution.sol:215: require(positionOf[tokenId].expiry == uint32(0), "POSITION_NOT_UNLOCKED"); XDEFIDistribution.sol:227: require(_exists(tokenId_), "NO_TOKEN"); XDEFIDistribution.sol:232: require(_exists(tokenId_), "NO_TOKEN"); XDEFIDistribution.sol:255: require(amount_ != uint256(0) && amount_ <= MAX_TOTAL_XDEFI_SUPPLY, "INVALID_AMOUNT"); XDEFIDistribution.sol:259: require(bonusMultiplier != uint8(0), "INVALID_DURATION"); XDEFIDistribution.sol:295: require(ownerOf(tokenId_) == account_, "NOT_OWNER"); XDEFIDistribution.sol:304: require(expiry != uint32(0), "NO_LOCKED_POSITION"); XDEFIDistribution.sol:305: require(block.timestamp >= uint256(expiry), "CANNOT_UNLOCK"); XDEFIDistribution.sol:322: require(count > uint256(1), "USE_UNLOCK");
VS Code
Replace revert strings with custom errors.
#0 - deluca-mike
2022-01-05T08:53:06Z
Agreed. I like this, and it will be done.
#1 - deluca-mike
2022-01-13T21:45:41Z
Added to release candidate interface and used through the release candidate contract (you will notice no more require
or assert
usage).
🌟 Selected for report: Dravee
100.2321 USDC - $100.23
Dravee
For SSTORE
: the current cost per one 32-byte word is 20,000 Gas
For SLOAD
, it's 800 gas.
In the XDEFIDistribution.sol
contract, here's the proposeOwnership
function:
function proposeOwnership(address newOwner_) external onlyOwner { emit OwnershipProposed(owner, pendingOwner = newOwner_); }
A require statement should be there to avoid a SSTORE
, such as if pendingOwner
is already the newOwner
or if pendingOwner
is the 0 address.
However, this check would cost at least a SLOAD and an MLOAD.
VS Code
Applying the check is all about estimating the risk-cost of a mistake in the proposeOwnership
function call. As it's an external function and there's a 1/20 ratio between the SLOAD cost and the SSTORE cost, I'd recommend applying the check if you think there's a >5% chance of calling the method with the currently pending owner as a parameter by mistake
#0 - deluca-mike
2022-01-05T08:18:26Z
It is very very unlikely that the owner will be reset to the same address it already was, or zero. Reduction of costs for happy path is always prefered, even if it makes sad paths more expensive.
#1 - Ivshti
2022-01-16T06:02:18Z
agreed, this would be an extra check to optimize a case that will probably never happen otherwise technically valid
🌟 Selected for report: Dravee
100.2321 USDC - $100.23
Dravee
Solidity contracts have contiguous 32 bytes (256 bits) slots used in storage. By arranging the variables, it is possible to minimize the number of slots used within a contract's storage and therefore reduce deployment costs.
In XDEFIDistribution.sol
, the order of variables is this way:
uint88 internal MAX_TOTAL_XDEFI_SUPPLY = uint88(240_000_000_000_000_000_000_000_000); uint256 internal constant _pointsMultiplier = uint256(2**128); uint256 internal _pointsPerUnit; address public immutable XDEFI; uint256 public distributableXDEFI; uint256 public totalDepositedXDEFI; uint256 public totalUnits; mapping(uint256 => Position) public positionOf; mapping(uint256 => uint8) public bonusMultiplierOf; // Scaled by 100 (i.e. 1.1x is 110, 2.55x is 255). uint256 internal immutable _zeroDurationPointBase; string public baseURI; address public owner; address public pendingOwner; uint256 internal _locked;
As we can see, the first variable is a uint88
, which is a 12 bytes size variable (way less than 32 bytes). However, due to it's wrong position, it will take up a whole 32 bytes slot.
As an address
type variable is of size 20 bytes, it would be better to make them contiguous so that they are packed together in a 32 bytes slot.
As for the string
variable, it's a dynamic size variable, so it'll always take up a new slot
Pack the variables in XDEFIDistribution.sol
such that the number of slots used in deployment is minimised. The only optimization I can see here is moving the uint88
variable (12 bytes) next to an address
variable (20 bytes).
#0 - deluca-mike
2022-01-05T09:53:03Z
There are no longer any real packing benefits here since MAX_TOTAL_XDEFI_SUPPLY
should be constant (and thus not in storage) and is actually getting removed.