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: 19/40
Findings: 3
Award: $178.02
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: leastwood
Also found by: MaCree, WatchPug, cmichel, egjlmn1, kenzo, onewayfunction, sirhashalot
140.1442 USDC - $140.14
leastwood
NFTs are used to represent unique positions referenced by the generated tokenId
. The tokenId
value contains the position's score in the upper 128 bits and the index wrt. the token supply in the lower 128 bits.
When positions are unlocked after expiring, the relevant position stored in the positionOf
mapping is deleted, however, the NFT is not. The merge()
function is used to combine points in unlocked NFTs, burning the underlying NFTs upon merging. As a result, _generateNewTokenId()
may end up using the same totalSupply()
value, causing _safeMint()
to fail if the same amount_
and duration_
values are used.
This edge case only occurs if there is an overlap in the points_
and totalSupply() + 1
values used to generate tokenId
. As a result, this may impact a user's overall experience while interacting with the XDEFI
protocol, as some transactions may fail unexpectedly.
function _lock(uint256 amount_, uint256 duration_, address destination_) internal returns (uint256 tokenId_) { // Prevent locking 0 amount in order generate many score-less NFTs, even if it is inefficient, and such NFTs would be ignored. require(amount_ != uint256(0) && amount_ <= MAX_TOTAL_XDEFI_SUPPLY, "INVALID_AMOUNT"); // Get bonus multiplier and check that it is not zero (which validates the duration). uint8 bonusMultiplier = bonusMultiplierOf[duration_]; require(bonusMultiplier != uint8(0), "INVALID_DURATION"); // Mint a locked staked position NFT to the destination. _safeMint(destination_, tokenId_ = _generateNewTokenId(_getPoints(amount_, duration_))); // Track deposits. totalDepositedXDEFI += amount_; // Create Position. uint96 units = uint96((amount_ * uint256(bonusMultiplier)) / uint256(100)); totalUnits += units; positionOf[tokenId_] = Position({ units: units, depositedXDEFI: uint88(amount_), expiry: uint32(block.timestamp + duration_), created: uint32(block.timestamp), bonusMultiplier: bonusMultiplier, pointsCorrection: -_toInt256Safe(_pointsPerUnit * units) }); emit LockPositionCreated(tokenId_, destination_, amount_, duration_); }
function _generateNewTokenId(uint256 points_) internal view returns (uint256 tokenId_) { // Points is capped at 128 bits (max supply of XDEFI for 10 years locked), total supply of NFTs is capped at 128 bits. return (points_ << uint256(128)) + uint128(totalSupply() + 1); }
function merge(uint256[] memory tokenIds_, address destination_) external returns (uint256 tokenId_) { uint256 count = tokenIds_.length; require(count > uint256(1), "MIN_2_TO_MERGE"); uint256 points; // For each NFT, check that it belongs to the caller, burn it, and accumulate the points. for (uint256 i; i < count; ++i) { uint256 tokenId = tokenIds_[i]; require(ownerOf(tokenId) == msg.sender, "NOT_OWNER"); require(positionOf[tokenId].expiry == uint32(0), "POSITION_NOT_UNLOCKED"); _burn(tokenId); points += _getPointsFromTokenId(tokenId); } // Mine a new NFT to the destinations, based on the accumulated points. _safeMint(destination_, tokenId_ = _generateNewTokenId(points)); }
Manual code review. Discussions with Michael.
Consider replacing totalSupply()
in _generateNewTokenId()
with an internal counter. This should ensure that _generateNewTokenId()
always returns a unique tokenId
that is monotomically increasing .
#0 - deluca-mike
2022-01-09T05:06:12Z
Valid
#1 - deluca-mike
2022-01-13T21:38:00Z
In the release candidate contract, _generateNewTokenId
now used an internal _tokensMinted
variable instead of totalSupply()
, as seen here.
#2 - Ivshti
2022-01-16T00:44:24Z
Agreed with sponsor
As for mitigation, the new way to generate token IDs seems cleaner, but more gas consuming
30.2712 USDC - $30.27
leastwood
Any account can deposit XDEFI
into the XDEFIDistribution.sol
contract for whatever reason. The difference in deposited XDEFI
(received by locking tokens) and the underlying contract balance is utilised as a sort of yield distributed to protocol participants.
The updateDistribution()
function is called whenever users want to update the _pointsPerUnit
value, representing a user's claim on the underlying XDEFI
tokens received externally. However, as there is no incentive or fixed interval by which updateDistribution()
is called, users may unexpectedly lose value if they unlock tokens without first updating the _pointsPerUnit
value. Similarly, a user could also frontrun an update to this value, effectively siphoning a small amount of yield. If the minimum lock period is sufficiently small, this can be repeated over and over again to generate additional yield as compared to other market participants. The user will then be able to utilise their capital by deploying it within other protocols when it is not feasible to siphon additional yield from XDEFIDistribution
.
function updateDistribution() external { uint256 totalUnitsCached = totalUnits; require(totalUnitsCached > uint256(0), "NO_UNIT_SUPPLY"); uint256 newXDEFI = _toUint256Safe(_updateXDEFIBalance()); if (newXDEFI == uint256(0)) return; _pointsPerUnit += ((newXDEFI * _pointsMultiplier) / totalUnitsCached); emit DistributionUpdated(msg.sender, newXDEFI); }
Manual code review
Consider adding incentives for continuous and frequent calls to updateDistribution()
. Chainlink keepers can be effectively used as a solution, or a similar implementation funded by XDEFI
tokens.
Additionally, it may also be useful to replace the internal call _updateXDEFIBalance()
with a call that updates the updateDistribution
value. This will also help to maintain protocol liveness.
#0 - deluca-mike
2022-01-09T06:18:37Z
While technically valid, this is not really an issue at all. The intended flow for a rewards distribution (in this case the XDEFI organization) is to, in one transaction, send XDEFI to the XDEFIDistribution
contract, and call updateDistribution()
in order to account for it. The reason for this flow is that the XDEFI organization treasury has the ability may have the ability to send XDEFI to an address, but not the ability to call updateDistribution()
, and the XDEFI organization admin wallet may have the ability to call updateDistribution()
, but not have the XDEFI needed for rewards. Thus, the XDEFI organization wallet would do a multi-call (something like a Gnosis Safe) which first instructs the treasury to send XDEFi to the XDEFIDistribution
contract, and then calls updateDistribution()
.
The onus is on the reward distributor to call updateDistribution()
in order to account for new rewards. The distributor doing something incorrect which results in the reward not being accounted for, and thus lost, is just as bad, and possible, as the distributor sending token to a different address. If we can't trust the distributor to use the contract properly, then we are already lost.
Now, a solution for other issues found in this audit just happened to be to precede all locks and unlocks with updateDistribution()
, which is a bit similar to the Recommended Mitigation Steps
here, expect done at the start, and not at the end.
#1 - Ivshti
2022-01-16T01:43:49Z
closing
finding considered valid and low severity due to the explanation by the sponsor.
7.6096 USDC - $7.61
leastwood
The noReenter
modifier can be done more efficiently by using 1
and 2
to represent unlocked and locked states respectively. This is due to the fact that there are additional gas costs when updating a slow from a zero value to a non-zero value. The gas refunded when resetting the slot back to its zero value does not cover the cost of the initial change.
modifier noReenter() { require(_locked == 0, "LOCKED"); _locked = uint256(1); _; _locked = uint256(0); }
Manual code review.
Consider using 1
and 2
to represent the unlocked and locked states respectively.
#0 - deluca-mike
2022-01-05T08:46:46Z
For some cases 0 -> 1 -> 0
is cheaper, but after some testing, its overall cheaper to use 1 -> 2 -> 1
(specifically in the batch functions).
#1 - deluca-mike
2022-01-09T10:24:37Z
Duplicate #1