Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

_safeMint Will Fail Due To An Edge Case In Calculating tokenId Using The _generateNewTokenId Function #17

Open
code423n4 opened this issue Jan 4, 2022 · 3 comments
Labels
2 (Med Risk) Assets not at direct risk, but function/availability of the protocol could be impacted or leak value bug Something isn't working resolved Finding has been patched by sponsor (sponsor pls link to PR containing fix) sponsor confirmed Sponsor agrees this is a problem and intends to fix it (OK to use w/ "disagree with severity")

Comments

@code423n4
Copy link
Contributor

Handle

leastwood

Vulnerability details

Impact

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.

Proof of Concept

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));
}

Tools Used

Manual code review.
Discussions with Michael.

Recommended Mitigation Steps

Consider replacing totalSupply() in _generateNewTokenId() with an internal counter. This should ensure that _generateNewTokenId() always returns a unique tokenId that is monotomically increasing .

@deluca-mike
Copy link
Collaborator

Valid

@deluca-mike deluca-mike added the sponsor confirmed Sponsor agrees this is a problem and intends to fix it (OK to use w/ "disagree with severity") label Jan 9, 2022
@deluca-mike
Copy link
Collaborator

deluca-mike commented Jan 13, 2022

In the release candidate contract, _generateNewTokenId now used an internal _tokensMinted variable instead of totalSupply(), as seen here.

@deluca-mike deluca-mike added the resolved Finding has been patched by sponsor (sponsor pls link to PR containing fix) label Jan 14, 2022
@Ivshti
Copy link
Member

Ivshti commented Jan 16, 2022

Agreed with sponsor

As for mitigation, the new way to generate token IDs seems cleaner, but more gas consuming

@Ivshti Ivshti closed this as completed Jan 16, 2022
@CloudEllie CloudEllie reopened this Jan 19, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2 (Med Risk) Assets not at direct risk, but function/availability of the protocol could be impacted or leak value bug Something isn't working resolved Finding has been patched by sponsor (sponsor pls link to PR containing fix) sponsor confirmed Sponsor agrees this is a problem and intends to fix it (OK to use w/ "disagree with severity")
Projects
None yet
Development

No branches or pull requests

4 participants