Pieces Protocol

First Flight #32
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Invalid

Attacker Can Sell Less Than Divided Token Pieces

Summary

An attacker can sell less than divided pieces, thus leading to buyers holding tokens that they cannot use to claim.

Vulnerability Details

There is no check in the sellErc20() function that verifies that the amount of tokens listed (to be sold) are indeed the total amount of dividend token pieces.

Impact

A user can buy token pieces from the marketplace under the assumption that the listed tokens are all the dividend token pieces. When that user goes to claim the NFT, they get reverted with an insufficient amount error.

PoC:

  • Alice divides her NFT into 5 token pieces

  • Alice lists only 5 pieces to be sold

  • Bob buys all 5 pieces under the assumption that they are all the dividend token pieces for the NFT

  • Bob goes to claim, but gets reverted

  • Alice lists the remaining 5 pieces

  • Unsuspecting Clara buys all 5 of the newly listed tokens under the assumption that they are all the dividend token pieces for the NFT

  • Clara goes to claim, but also gets reverted

  • Bob and Clara are left with token pieces which they can not claim

  • If there is no way to know who has the remaining pieces, these tokens become totally useless

Add the following test to the test file:

function test_can_sell_less_than_pieces_divided() public nftDivided {
ERC20Mock erc20Mock = ERC20Mock(tokenDivider.getErc20InfoFromNft(address(erc721Mock)).erc20Address);
// USER creates sell order for just one token piece
vm.startPrank(USER);
erc20Mock.approve(address(tokenDivider), 1e18);
tokenDivider.sellErc20(address(erc721Mock), 1e18, 1e18);
vm.stopPrank();
assertEq(tokenDivider.getBalanceOf(USER, address(erc20Mock)), 1e18);
assertEq(erc20Mock.balanceOf(address(tokenDivider)), 1e18);
// USER2 buys that token piece, assuming it is the only token piece dividend
vm.prank(USER2);
tokenDivider.buyOrder{value: (2e18)}(0, USER);
// USER2 attempts to claim
vm.startPrank(USER2);
erc20Mock.approve(address(tokenDivider), 1e18);
vm.expectRevert();
tokenDivider.claimNft(address(erc721Mock));
vm.stopPrank();
// USER creates sell order for the remaining token piece
vm.startPrank(USER);
erc20Mock.approve(address(tokenDivider), 1e18);
tokenDivider.sellErc20(address(erc721Mock), 1e18, 1e18);
vm.stopPrank();
// another user buys this token piece, also assuming it is the only token piece dividend
address patrick = makeAddr("unaware_buyer");
vm.deal(patrick, 2e18);
vm.prank(patrick);
tokenDivider.buyOrder{value: 2e18}(0, USER);
// patrick attempts to claim
vm.startPrank(patrick);
erc20Mock.approve(address(tokenDivider), 1e18);
vm.expectRevert();
tokenDivider.claimNft(address(erc721Mock));
vm.stopPrank();
}

Tools Used

  • Manual Review

  • Foundry

Recommendations

  • Add a check in the sellErc20()that specifies that the amount of tokens to be sold is == the total number of dividend token pieces of the NFT.

function sellErc20(address nftPegged, uint256 price, uint256 amount) external {
if (nftPegged == address(0)) {
revert TokenDivider__NftAddressIsZero();
}
if (amount == 0) {
revert TokenDivider__AmountCantBeZero();
}
ERC20Info memory tokenInfo = nftToErc20Info[nftPegged];
if (balances[msg.sender][tokenInfo.erc20Address] < amount) {
revert TokenDivider__InsuficientBalance();
}
+ // Ensure the amount matches the total ERC20 tokens minted for the NFT
+ require(
+ amount == erc20ToMintedAmount[tokenInfo.erc20Address],
+ "TokenDivider: Amount must match total divided pieces"
+ );
balances[msg.sender][tokenInfo.erc20Address] -= amount;
s_userToSellOrders[msg.sender].push(
SellOrder({seller: msg.sender, erc20Address: tokenInfo.erc20Address, price: price, amount: amount})
);
emit OrderPublished(amount, msg.sender, nftPegged);
IERC20(tokenInfo.erc20Address).transferFrom(msg.sender, address(this), amount);
}

Here is a test to confirm this mitigation:

function test_cannot_sell_less_than_pieces_divided() public nftDivided {
ERC20Mock erc20Mock = ERC20Mock(tokenDivider.getErc20InfoFromNft(address(erc721Mock)).erc20Address);
// USER creates sell order for just one token piece
vm.startPrank(USER);
erc20Mock.approve(address(tokenDivider), AMOUNT);
vm.expectRevert("TokenDivider: Amount must match total divided pieces");
tokenDivider.sellErc20(address(erc721Mock), 1e18, 1e18);
vm.stopPrank();
}
  • Also, remove the transferErcTokens()function as there is no need for it.

Updates

Lead Judging Commences

fishy Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Appeal created

yeahchibyke Submitter
9 months ago
fishy Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.