Platform: Code4rena
Start Date: 24/02/2022
Pot Size: $75,000 USDC
Total HM: 21
Participants: 28
Period: 7 days
Judge: alcueca
Total Solo HM: 15
Id: 94
League: ETH
Rank: 4/28
Findings: 5
Award: $7,181.55
π Selected for report: 2
π Solo Findings: 2
Given:
createReserveAuction
auction.endTime
When:
makeOffer
with: 1 ETHacceptOffer
Expected Results:
1 ETH - fee
;Actual Results:
Given:
createReserveAuction
1 ETH
auction.endTime
When:
setBuyPrice(nft.address, tokenId, 1 ETH)
Expected Results:
setBuyPrice()
L156 _autoAcceptOffer()
to process the transactions same as the expectations of Case A;Actual Results:
bidder acceptOffer
will goes to L270 of NFTMarketOffer
and calls _transferFromEscrow(nftContract, tokenId, offer.buyer, msg.sender)
:
function _acceptOffer(address nftContract, uint256 tokenId) private { Offer memory offer = nftContractToIdToOffer[nftContract][tokenId]; // Remove offer delete nftContractToIdToOffer[nftContract][tokenId]; // Withdraw ETH from the buyer's account in the FETH token contract. feth.marketWithdrawLocked(offer.buyer, offer.expiration, offer.amount); // Distribute revenue for this sale leveraging the ETH received from the FETH contract in the line above. (uint256 f8nFee, uint256 creatorFee, uint256 ownerRev) = _distributeFunds( nftContract, tokenId, payable(msg.sender), offer.amount ); // Transfer the NFT to the buyer. try IERC721(nftContract).transferFrom(msg.sender, offer.buyer, tokenId) // solhint-disable-next-line no-empty-blocks { // NFT was in the seller's wallet so the transfer is complete. } catch { // If the transfer fails then attempt to transfer from escrow instead. // This should revert if the NFT is not in escrow of the `msg.sender` is not the owner of this NFT. _transferFromEscrow(nftContract, tokenId, offer.buyer, msg.sender); } emit OfferAccepted(nftContract, tokenId, offer.buyer, msg.sender, f8nFee, creatorFee, ownerRev); }
_transferFromEscrow(nftContract, tokenId, offer.buyer, msg.sender)
will goes to L557 of NFTMarketReserveAuction
and calls _finalizeReserveAuction(auctionId, false)
:
function _transferFromEscrow( address nftContract, uint256 tokenId, address recipient, address seller ) internal virtual override { uint256 auctionId = nftContractToTokenIdToAuctionId[nftContract][tokenId]; if (auctionId != 0) { ReserveAuction storage auction = auctionIdToAuction[auctionId]; if (auction.endTime == 0) { // The auction has not received any bids yet so it may be invalided. if (auction.seller != seller) { // The account trying to transfer the NFT is not the current owner. revert NFTMarketReserveAuction_Not_Matching_Seller(auction.seller); } // Remove the auction. delete nftContractToTokenIdToAuctionId[nftContract][tokenId]; delete auctionIdToAuction[auctionId]; emit ReserveAuctionInvalidated(auctionId); } else { // If the auction has started, the highest bidder will be the new owner. if (auction.bidder != seller) { revert NFTMarketReserveAuction_Not_Matching_Seller(auction.bidder); } // Finalization will revert if the auction has not yet ended. _finalizeReserveAuction(auctionId, false); // Finalize includes the transfer, so we are done here. return; } } super._transferFromEscrow(nftContract, tokenId, recipient, seller); }
_finalizeReserveAuction(auctionId, false)
will trasfer the NFT to the bidder only at L506 of NFTMarketReserveAuction.sol
.
After _finalizeReserveAuction(auctionId, false)
, it goes back to L560 of NFTMarketReserveAuction.sol
, which is a return
, and then goes back to L273 of NFTMarketOffer.sol
and that's the end of acceptOffer()
γ
As a result, the NFT will not be transferred from the bidder to the buyer, in spite of the buyer's ETH payment being paid at L255 of NFTMarketOffer.sol
(_distributeFunds()
).
function _finalizeReserveAuction(uint256 auctionId, bool keepInEscrow) private { ReserveAuction memory auction = auctionIdToAuction[auctionId]; if (auction.endTime >= block.timestamp) { revert NFTMarketReserveAuction_Cannot_Finalize_Auction_In_Progress(auction.endTime); } // Remove the auction. delete nftContractToTokenIdToAuctionId[auction.nftContract][auction.tokenId]; delete auctionIdToAuction[auctionId]; if (!keepInEscrow) { /* * Save gas by calling core directly since it cannot have another escrow requirement * (buy price set or another auction listed) until this one has been finalized. */ NFTMarketCore._transferFromEscrow(auction.nftContract, auction.tokenId, auction.bidder, address(0)); } // Distribute revenue for this sale. (uint256 f8nFee, uint256 creatorFee, uint256 ownerRev) = _distributeFunds( auction.nftContract, auction.tokenId, auction.seller, auction.amount ); emit ReserveAuctionFinalized(auctionId, auction.seller, auction.bidder, f8nFee, creatorFee, ownerRev); }
When there is an unfinalized ReserveAuction, consider preventing acceptOffer
and makeOffer
.
#0 - HardlyDifficult
2022-03-02T22:11:24Z
Duplicate of https://github.com/code-423n4/2022-02-foundation-findings/issues/49
This is a very good catch and we have a fix which will be included.
The protocol is charging a different fee rate when the NFT is sold by the creator for the first time on the platform, the fee rate is currently set to a constant value of 15%.
For other sales, the fee rate is only 5%.
if (isCreator && !_nftContractToTokenIdToFirstSaleCompleted[nftContract][tokenId]) { fee = PRIMARY_FOUNDATION_FEE_BASIS_POINTS; } else { fee = SECONDARY_FOUNDATION_FEE_BASIS_POINTS; }
However, since the smart contract is checking if the seller's address equals the creator's for, by:
the 15% PRIMARY_FOUNDATION_FEE
can effectively be bypassed.
Since the benefit of doing so is considerable (paying only 1/3 of the fees), while the cost is low. We would argue that this creator listing fee bypassing can and will eventually be implemented, for example, a smart contract listing agent with UI can be popular.
We don't see a way to prevent this higher fee for the creator mechanism from being bypassed.
Instead, we would recommend reversing the fee rate settings and making the creator pay a lower fee, and therefore, encourages creators to list their artwork on this platform rather than other platforms.
#0 - HardlyDifficult
2022-03-02T22:33:33Z
π Selected for report: WatchPug
2434.7549 USDC - $2,434.75
Based on our research, getRoyalties()
is not a standardized API for NFT contracts to indicate how the royalties should be distributed among the recipients.
However, in the current implementation, it always assumes that getRoyalties()
return in terms of BPS.
if (creatorShares[i] > BASIS_POINTS) { // If the numbers are >100% we ignore the fee recipients and pay just the first instead maxCreatorIndex = 0; break; }
As a result, if a particular implementation is returning get Royalties()
with higher precision (say 1e6 for 100% instead of 1e4/BPS), the distribution of royalties can be distorted.
Given:
NFT-Token1
Royalties
:
Alice owns the NFT-Token1
Alice setBuyPrice()
and listed NFT-Token1
for 10 ETH
;
Bob buy()
with 10 ETH
:
foundationFee
= 0.5 ETH
creatorFee
= 1 ETH
ownerRev
= 8.5 ETH
Since creatorShares[address2]
> BASIS_POINTS
(10,000), all the creatorFee
will be sent to the first address: Address0
, which is expected to receive only 1%
of the royalties.
Consider removing this and change to:
// Determine the total shares defined so it can be leveraged to distribute below uint256 totalShares; unchecked { // The array length cannot overflow 256 bits. for (uint256 i = 0; i <= maxCreatorIndex; ++i) { // The check above ensures totalShares wont overflow. totalShares += creatorShares[i]; } } if (totalShares == 0) { maxCreatorIndex = 0; }
#0 - HardlyDifficult
2022-03-08T19:56:49Z
This is a fair concern to raise, and a reasonable solution recommendation. We will consider making a change like this in the future.
AFAIK this API was first introduced by Manifold, which named and implemented the return value to be in basis points. It's true that since this is not a standard that others may use a different precision, however we have not yet encountered any example of that.
π Selected for report: WatchPug
2434.7549 USDC - $2,434.75
if (nftContract.supportsERC165Interface(type(IRoyaltyInfo).interfaceId)) { try IRoyaltyInfo(nftContract).royaltyInfo{ gas: READ_ONLY_GAS_LIMIT }(tokenId, BASIS_POINTS) returns ( address receiver, uint256 /* royaltyAmount */ ) { if (receiver != address(0)) { recipients = new address payable[](1); recipients[0] = payable(receiver); // splitPerRecipientInBasisPoints is not relevant when only 1 recipient is defined if (receiver == seller) { return (recipients, splitPerRecipientInBasisPoints, true); } } } catch // solhint-disable-next-line no-empty-blocks { // Fall through } }
The current implementation of EIP-2981 support will always pass a constant BASIS_POINTS
as the _salePrice
.
As a result, the recipients that are supposed to receive less than 1 BPS of the salePrice may end up not receiving any royalties.
Furthermore, for the NFTs with the total royalties rate set less than 10% for some reason, the current implementation will scale it up to 10%.
_salePrice
, we suggest using the actual _salePrice
, so there the royalties can be paid for recipients with less than 1 BPS of the royalties.#0 - HardlyDifficult
2022-03-08T20:03:15Z
Yes, these are valid points and something we will consider revisiting in the future.
RE recommendations:
π Selected for report: leastwood
Also found by: 0x1f8b, 0xliumin, 0xwags, CertoraInc, Dravee, IllIllI, Ruhum, TerrierLover, WatchPug, cmichel, csanuragjain, defsec, gzeon, hubble, jayjonah8, kenta, kirk-baird, rfa, robee
175.5551 USDC - $175.56
for (uint256 i = 0; i < _recipients.length; ++i) { if (_recipients[i] != address(0)) { hasRecipient = true; if (_recipients[i] == seller) { return (_recipients, recipientBasisPoints, true); } } }
In the current implementation of NFTMarketCreators.sol#_getCreatorPaymentInfo()
, when the seller is one of the royalty recipients, isCreator
will be set to true
.
In NFTMarketFees.sol#_getFees()
, when isCreator
is true
, in spite of there may be other creatorRecipients
, all revenue is split, and ownerRev
will be left as the deafult value of 0
.
if (isCreator) { // When sold by the creator, all revenue is split if applicable. creatorRev = price - foundationFee; }
AS a result, in NFTMarketFees.sol#_distributeFunds()
the creatorFee
will be distributed to all creatorRecipients
based on creatorShares
.
uint256 totalDistributed; for (uint256 i = 1; i <= maxCreatorIndex; ++i) { uint256 share = (creatorFee * creatorShares[i]) / totalShares; totalDistributed += share; _sendValueWithFallbackWithdraw(creatorRecipients[i], share, SEND_VALUE_GAS_LIMIT_MULTIPLE_RECIPIENTS); }
If the owner/seller of the NFT is one of the creatorRecipients
, they can not get the expected revenue, instead, most of the revenue can be distributed to the other _recipients
.
Given:
NFT-Token1 Royalties
:
9,000
1,000
Bob owns the NFT-Token1
Bob setBuyPrice()
and listed NFT-Token1
for 10 ETH
;
Charle buy()
NFT-Token1
with 10 ETH
:
foundationFee
= 1.5 ETH
creatorFee
= 8.5 ETH
as a result:
Alice
received 7.65 ETH
Bob
received 0.85 ETH
Change to:
if (creatorRecipients.length > 0) { if (isCreator && creatorRecipients.length == 1) { ownerRevTo = seller; ownerRev = price - foundationFee; } else { // Rounding favors the owner first, then creator, and foundation last. creatorRev = (price * CREATOR_ROYALTY_BASIS_POINTS) / BASIS_POINTS; ownerRevTo = seller; ownerRev = price - foundationFee - creatorRev; } } else { // No royalty recipients found. ownerRevTo = seller; ownerRev = price - foundationFee; }
#0 - HardlyDifficult
2022-03-07T13:04:05Z
This is a fair concern as some users would not expect the market to pay royalties in this way, but it is how we intended it to work.
The thinking behind this is we wanted any rev share defined by the NFT to be respected in a way that collectors might expect. An example is if the NFT has declared that 50% of the revenue will go to a charity. It's not immediately obvious if all the revenue from the initial sale, for example, should be split with the charity or if this is only referring to the 10% royalties. We have chosen to lean in favor of splitting anywhere it seems reasonable to do so to try and minimize misleading deals like this. In order for that to continue to work as expected, we did not want to pursue a change like the suggested && creatorRecipients.length == 1
.
We added a comment to help clarify the expected behavior here.
However another related issue that was raised lead to a significant change in the logic here. It mitigates some of the concern raised here as well: https://github.com/code-423n4/2022-02-foundation-findings/issues/30
#1 - alcueca
2022-03-14T11:51:30Z
Downgrading this to a code clarity issue.
#2 - alcueca
2022-03-17T10:04:28Z
Score as a QA Report: 5
#3 - CloudEllie
2022-03-24T19:02:32Z
Since this issue was downgraded to a QA level, and the warden did not submit a separate QA report, we've renamed this one to "QA report" for consistency.
The original title, for the record, was "Inappropriate implementation of NFTMarketFees#_getFees() can result in incorrect distribution when the seller is one of the royalty recipients."