diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..56a7d8c30 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "solidity.packageDefaultDependenciesContractsDirectory": "src", + "solidity.packageDefaultDependenciesDirectory": "lib" +} diff --git a/contracts/prebuilts/marketplace/IMarketplace.sol b/contracts/prebuilts/marketplace/IMarketplace.sol index 0b9e05bcf..71889ee07 100644 --- a/contracts/prebuilts/marketplace/IMarketplace.sol +++ b/contracts/prebuilts/marketplace/IMarketplace.sol @@ -159,23 +159,6 @@ interface IDirectListings { uint256 _pricePerTokenInCurrency ) external; - /** - * @notice Buy NFTs from a listing. - * - * @param _listingId The ID of the listing to update. - * @param _buyFor The recipient of the NFTs being bought. - * @param _quantity The quantity of NFTs to buy from the listing. - * @param _currency The currency to use to pay for NFTs. - * @param _expectedTotalPrice The expected total price to pay for the NFTs being bought. - */ - function buyFromListing( - uint256 _listingId, - address _buyFor, - uint256 _quantity, - address _currency, - uint256 _expectedTotalPrice - ) external payable; - /** * @notice Returns the total number of listings created. * @dev At any point, the return value is the ID of the next listing created. diff --git a/contracts/prebuilts/marketplace/direct-listings/MintraDirectListings.sol b/contracts/prebuilts/marketplace/direct-listings/MintraDirectListings.sol new file mode 100644 index 000000000..371593a31 --- /dev/null +++ b/contracts/prebuilts/marketplace/direct-listings/MintraDirectListings.sol @@ -0,0 +1,686 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb.com / mintra.ai + +import "./DirectListingsStorage.sol"; + +// ====== External imports ====== +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/interfaces/IERC2981.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +// ====== Internal imports ====== +import "../../../eip/interface/IERC721.sol"; +import "../../../extension/Multicall.sol"; +import "../../../extension/upgradeable/ReentrancyGuard.sol"; +import { CurrencyTransferLib } from "../../../lib/CurrencyTransferLib.sol"; + +/** + * @author thirdweb.com / mintra.ai + */ +contract MintraDirectListings is IDirectListings, Multicall, ReentrancyGuard { + /*/////////////////////////////////////////////////////////////// + Mintra + //////////////////////////////////////////////////////////////*/ + struct Royalty { + address receiver; + uint256 basisPoints; + } + + event MintraNewSale( + uint256 listingId, + address buyer, + uint256 quantityBought, + uint256 totalPricePaid, + address currency + ); + + event MintraRoyaltyTransfered( + address assetContract, + uint256 tokenId, + uint256 listingId, + uint256 totalPrice, + uint256 royaltyAmount, + uint256 platformFee, + address royaltyRecipient, + address currency + ); + + event RoyaltyUpdated(address assetContract, uint256 royaltyAmount, address royaltyRecipient); + event PlatformFeeUpdated(uint256 platformFeeBps); + + address public immutable wizard; + address private immutable mintTokenAddress; + address public immutable platformFeeRecipient; + uint256 public platformFeeBps = 225; + uint256 public platformFeeBpsMint = 150; + mapping(address => Royalty) public royalties; + + /*/////////////////////////////////////////////////////////////// + Constants / Immutables + //////////////////////////////////////////////////////////////*/ + + /// @dev The max bps of the contract. So, 10_000 == 100 % + uint64 private constant MAX_BPS = 10_000; + + /// @dev The address of the native token wrapper contract. + address private immutable nativeTokenWrapper; + + /*/////////////////////////////////////////////////////////////// + Modifier + //////////////////////////////////////////////////////////////*/ + + modifier onlyWizard() { + require(msg.sender == wizard, "Not Wizard"); + _; + } + + /// @dev Checks whether caller is a listing creator. + modifier onlyListingCreator(uint256 _listingId) { + require( + _directListingsStorage().listings[_listingId].listingCreator == msg.sender, + "Marketplace: not listing creator." + ); + _; + } + + /// @dev Checks whether a listing exists. + modifier onlyExistingListing(uint256 _listingId) { + require( + _directListingsStorage().listings[_listingId].status == IDirectListings.Status.CREATED, + "Marketplace: invalid listing." + ); + _; + } + + /*/////////////////////////////////////////////////////////////// + Constructor logic + //////////////////////////////////////////////////////////////*/ + + constructor( + address _nativeTokenWrapper, + address _mintTokenAddress, + address _platformFeeRecipient, + address _wizard + ) { + nativeTokenWrapper = _nativeTokenWrapper; + mintTokenAddress = _mintTokenAddress; + platformFeeRecipient = _platformFeeRecipient; + wizard = _wizard; + } + + /*/////////////////////////////////////////////////////////////// + External functions + //////////////////////////////////////////////////////////////*/ + + /// @notice List NFTs (ERC721 or ERC1155) for sale at a fixed price. + function createListing(ListingParameters calldata _params) external returns (uint256 listingId) { + listingId = _getNextListingId(); + address listingCreator = msg.sender; + TokenType tokenType = _getTokenType(_params.assetContract); + + uint128 startTime = _params.startTimestamp; + uint128 endTime = _params.endTimestamp; + require(startTime < endTime, "Marketplace: endTimestamp not greater than startTimestamp."); + if (startTime < block.timestamp) { + require(startTime + 60 minutes >= block.timestamp, "Marketplace: invalid startTimestamp."); + + startTime = uint128(block.timestamp); + endTime = endTime == type(uint128).max + ? endTime + : startTime + (_params.endTimestamp - _params.startTimestamp); + } + + _validateNewListing(_params, tokenType); + + Listing memory listing = Listing({ + listingId: listingId, + listingCreator: listingCreator, + assetContract: _params.assetContract, + tokenId: _params.tokenId, + quantity: _params.quantity, + currency: _params.currency, + pricePerToken: _params.pricePerToken, + startTimestamp: startTime, + endTimestamp: endTime, + reserved: _params.reserved, + tokenType: tokenType, + status: IDirectListings.Status.CREATED + }); + + _directListingsStorage().listings[listingId] = listing; + + emit NewListing(listingCreator, listingId, _params.assetContract, listing); + + return listingId; + } + + /// @notice Update parameters of a listing of NFTs. + function updateListing( + uint256 _listingId, + ListingParameters memory _params + ) external onlyExistingListing(_listingId) onlyListingCreator(_listingId) { + address listingCreator = msg.sender; + Listing memory listing = _directListingsStorage().listings[_listingId]; + TokenType tokenType = _getTokenType(_params.assetContract); + + require(listing.endTimestamp > block.timestamp, "Marketplace: listing expired."); + + require( + listing.assetContract == _params.assetContract && listing.tokenId == _params.tokenId, + "Marketplace: cannot update what token is listed." + ); + + uint128 startTime = _params.startTimestamp; + uint128 endTime = _params.endTimestamp; + require(startTime < endTime, "Marketplace: endTimestamp not greater than startTimestamp."); + require( + listing.startTimestamp > block.timestamp || + (startTime == listing.startTimestamp && endTime > block.timestamp), + "Marketplace: listing already active." + ); + if (startTime != listing.startTimestamp && startTime < block.timestamp) { + require(startTime + 60 minutes >= block.timestamp, "Marketplace: invalid startTimestamp."); + + startTime = uint128(block.timestamp); + + endTime = endTime == listing.endTimestamp || endTime == type(uint128).max + ? endTime + : startTime + (_params.endTimestamp - _params.startTimestamp); + } + + { + uint256 _approvedCurrencyPrice = _directListingsStorage().currencyPriceForListing[_listingId][ + _params.currency + ]; + require( + _approvedCurrencyPrice == 0 || _params.pricePerToken == _approvedCurrencyPrice, + "Marketplace: price different from approved price" + ); + } + + _validateNewListing(_params, tokenType); + + listing = Listing({ + listingId: _listingId, + listingCreator: listingCreator, + assetContract: _params.assetContract, + tokenId: _params.tokenId, + quantity: _params.quantity, + currency: _params.currency, + pricePerToken: _params.pricePerToken, + startTimestamp: startTime, + endTimestamp: endTime, + reserved: _params.reserved, + tokenType: tokenType, + status: IDirectListings.Status.CREATED + }); + + _directListingsStorage().listings[_listingId] = listing; + + emit UpdatedListing(listingCreator, _listingId, _params.assetContract, listing); + } + + /// @notice Cancel a listing. + function cancelListing(uint256 _listingId) external onlyExistingListing(_listingId) onlyListingCreator(_listingId) { + _directListingsStorage().listings[_listingId].status = IDirectListings.Status.CANCELLED; + emit CancelledListing(msg.sender, _listingId); + } + + /// @notice Approve a buyer to buy from a reserved listing. + function approveBuyerForListing( + uint256 _listingId, + address _buyer, + bool _toApprove + ) external onlyExistingListing(_listingId) onlyListingCreator(_listingId) { + require(_directListingsStorage().listings[_listingId].reserved, "Marketplace: listing not reserved."); + + _directListingsStorage().isBuyerApprovedForListing[_listingId][_buyer] = _toApprove; + + emit BuyerApprovedForListing(_listingId, _buyer, _toApprove); + } + + /// @notice Approve a currency as a form of payment for the listing. + function approveCurrencyForListing( + uint256 _listingId, + address _currency, + uint256 _pricePerTokenInCurrency + ) external onlyExistingListing(_listingId) onlyListingCreator(_listingId) { + Listing memory listing = _directListingsStorage().listings[_listingId]; + require( + _currency != listing.currency || _pricePerTokenInCurrency == listing.pricePerToken, + "Marketplace: approving listing currency with different price." + ); + require( + _directListingsStorage().currencyPriceForListing[_listingId][_currency] != _pricePerTokenInCurrency, + "Marketplace: price unchanged." + ); + + _directListingsStorage().currencyPriceForListing[_listingId][_currency] = _pricePerTokenInCurrency; + + emit CurrencyApprovedForListing(_listingId, _currency, _pricePerTokenInCurrency); + } + + function bulkBuyFromListing( + uint256[] memory _listingId, + address[] memory _buyFor, + uint256[] memory _quantity, + address[] memory _currency, + uint256[] memory _expectedTotalPrice + ) external payable nonReentrant { + uint256 totalAmountPls = 0; + // Iterate over each tokenId + for (uint256 i = 0; i < _listingId.length; i++) { + // Are we buying this item in PLS + uint256 price; + + Listing memory listing = _directListingsStorage().listings[_listingId[i]]; + + require(listing.status == IDirectListings.Status.CREATED, "Marketplace: invalid listing."); + + if (_currency[i] == CurrencyTransferLib.NATIVE_TOKEN) { + //calculate total amount for items being sold for PLS + if (_directListingsStorage().currencyPriceForListing[_listingId[i]][_currency[i]] > 0) { + price = + _quantity[i] * + _directListingsStorage().currencyPriceForListing[_listingId[i]][_currency[i]]; + } else { + require(_currency[i] == listing.currency, "Paying in invalid currency."); + price = _quantity[i] * listing.pricePerToken; + } + + totalAmountPls += price; + } + + // Call the buy function for the current tokenId + _buyFromListing(listing, _buyFor[i], _quantity[i], _currency[i], _expectedTotalPrice[i]); + } + + // Make sure that the total price for items bought with PLS is equal to the amount sent + require(msg.value == totalAmountPls || (totalAmountPls == 0 && msg.value == 0), "Incorrect PLS amount sent"); + } + + /// @notice Buy NFTs from a listing. + function _buyFromListing( + Listing memory listing, + address _buyFor, + uint256 _quantity, + address _currency, + uint256 _expectedTotalPrice + ) internal { + uint256 listingId = listing.listingId; + address buyer = msg.sender; + + require( + !listing.reserved || _directListingsStorage().isBuyerApprovedForListing[listingId][buyer], + "buyer not approved" + ); + require(_quantity > 0 && _quantity <= listing.quantity, "Buying invalid quantity"); + require( + block.timestamp < listing.endTimestamp && block.timestamp >= listing.startTimestamp, + "not within sale window." + ); + + require( + _validateOwnershipAndApproval( + listing.listingCreator, + listing.assetContract, + listing.tokenId, + _quantity, + listing.tokenType + ), + "Marketplace: not owner or approved tokens." + ); + + uint256 targetTotalPrice; + + // Check: is the buyer paying in a currency that the listing creator approved + if (_directListingsStorage().currencyPriceForListing[listingId][_currency] > 0) { + targetTotalPrice = _quantity * _directListingsStorage().currencyPriceForListing[listingId][_currency]; + } else { + require(_currency == listing.currency, "Paying in invalid currency."); + targetTotalPrice = _quantity * listing.pricePerToken; + } + + // Check: is the buyer paying the price that the buyer is expecting to pay. + // This is to prevent attack where the seller could change the price + // right before the buyers tranaction executes. + require(targetTotalPrice == _expectedTotalPrice, "Unexpected total price"); + + if (_currency != CurrencyTransferLib.NATIVE_TOKEN) { + _validateERC20BalAndAllowance(buyer, _currency, targetTotalPrice); + } + + if (listing.quantity == _quantity) { + _directListingsStorage().listings[listingId].status = IDirectListings.Status.COMPLETED; + } + _directListingsStorage().listings[listingId].quantity -= _quantity; + + _payout(buyer, listing.listingCreator, _currency, targetTotalPrice, listing); + + _transferListingTokens(listing.listingCreator, _buyFor, _quantity, listing); + + emit MintraNewSale(listing.listingId, buyer, _quantity, targetTotalPrice, _currency); + } + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the total number of listings created. + * @dev At any point, the return value is the ID of the next listing created. + */ + function totalListings() external view returns (uint256) { + return _directListingsStorage().totalListings; + } + + /// @notice Returns whether a buyer is approved for a listing. + function isBuyerApprovedForListing(uint256 _listingId, address _buyer) external view returns (bool) { + return _directListingsStorage().isBuyerApprovedForListing[_listingId][_buyer]; + } + + /// @notice Returns whether a currency is approved for a listing. + function isCurrencyApprovedForListing(uint256 _listingId, address _currency) external view returns (bool) { + return _directListingsStorage().currencyPriceForListing[_listingId][_currency] > 0; + } + + /// @notice Returns the price per token for a listing, in the given currency. + function currencyPriceForListing(uint256 _listingId, address _currency) external view returns (uint256) { + if (_directListingsStorage().currencyPriceForListing[_listingId][_currency] == 0) { + revert("Currency not approved for listing"); + } + + return _directListingsStorage().currencyPriceForListing[_listingId][_currency]; + } + + /// @notice Returns all non-cancelled listings. + function getAllListings(uint256 _startId, uint256 _endId) external view returns (Listing[] memory _allListings) { + require(_startId <= _endId && _endId < _directListingsStorage().totalListings, "invalid range"); + + _allListings = new Listing[](_endId - _startId + 1); + + for (uint256 i = _startId; i <= _endId; i += 1) { + _allListings[i - _startId] = _directListingsStorage().listings[i]; + } + } + + /** + * @notice Returns all valid listings between the start and end Id (both inclusive) provided. + * A valid listing is where the listing creator still owns and has approved Marketplace + * to transfer the listed NFTs. + */ + function getAllValidListings( + uint256 _startId, + uint256 _endId + ) external view returns (Listing[] memory _validListings) { + require(_startId <= _endId && _endId < _directListingsStorage().totalListings, "invalid range"); + + Listing[] memory _listings = new Listing[](_endId - _startId + 1); + uint256 _listingCount; + + for (uint256 i = _startId; i <= _endId; i += 1) { + _listings[i - _startId] = _directListingsStorage().listings[i]; + if (_validateExistingListing(_listings[i - _startId])) { + _listingCount += 1; + } + } + + _validListings = new Listing[](_listingCount); + uint256 index = 0; + uint256 count = _listings.length; + for (uint256 i = 0; i < count; i += 1) { + if (_validateExistingListing(_listings[i])) { + _validListings[index++] = _listings[i]; + } + } + } + + /// @notice Returns a listing at a particular listing ID. + function getListing(uint256 _listingId) external view returns (Listing memory listing) { + listing = _directListingsStorage().listings[_listingId]; + } + + /** + * @notice Set or update the royalty for a collection + * @dev Sets or updates the royalty for a collection to a new value + * @param _collectionAddress Address of the collection to set the royalty for + * @param _royaltyInBasisPoints New royalty value, in basis points (1 basis point = 0.01%) + */ + function createOrUpdateRoyalty( + address _collectionAddress, + uint256 _royaltyInBasisPoints, + address receiver + ) public nonReentrant { + require(_collectionAddress != address(0), "_collectionAddress is not set"); + require(_royaltyInBasisPoints >= 0 && _royaltyInBasisPoints <= 10000, "Royalty not in range"); + require(receiver != address(0), "receiver is not set"); + + // Check that the caller is the owner/creator of the collection contract + require(Ownable(_collectionAddress).owner() == msg.sender, "Unauthorized"); + + // Create a new Royalty object with the given value and store it in the royalties mapping + Royalty memory royalty = Royalty(receiver, _royaltyInBasisPoints); + royalties[_collectionAddress] = royalty; + + // Emit a RoyaltyUpdated + emit RoyaltyUpdated(_collectionAddress, _royaltyInBasisPoints, receiver); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the next listing Id. + function _getNextListingId() internal returns (uint256 id) { + id = _directListingsStorage().totalListings; + _directListingsStorage().totalListings += 1; + } + + /// @dev Returns the interface supported by a contract. + function _getTokenType(address _assetContract) internal view returns (TokenType tokenType) { + if (IERC165(_assetContract).supportsInterface(type(IERC1155).interfaceId)) { + tokenType = TokenType.ERC1155; + } else if (IERC165(_assetContract).supportsInterface(type(IERC721).interfaceId)) { + tokenType = TokenType.ERC721; + } else { + revert("Marketplace: listed token must be ERC1155 or ERC721."); + } + } + + /// @dev Checks whether the listing creator owns and has approved marketplace to transfer listed tokens. + function _validateNewListing(ListingParameters memory _params, TokenType _tokenType) internal view { + require(_params.quantity > 0, "Marketplace: listing zero quantity."); + require(_params.quantity == 1 || _tokenType == TokenType.ERC1155, "Marketplace: listing invalid quantity."); + + require( + _validateOwnershipAndApproval( + msg.sender, + _params.assetContract, + _params.tokenId, + _params.quantity, + _tokenType + ), + "Marketplace: not owner or approved tokens." + ); + } + + /// @dev Checks whether the listing exists, is active, and if the lister has sufficient balance. + function _validateExistingListing(Listing memory _targetListing) internal view returns (bool isValid) { + isValid = + _targetListing.startTimestamp <= block.timestamp && + _targetListing.endTimestamp > block.timestamp && + _targetListing.status == IDirectListings.Status.CREATED && + _validateOwnershipAndApproval( + _targetListing.listingCreator, + _targetListing.assetContract, + _targetListing.tokenId, + _targetListing.quantity, + _targetListing.tokenType + ); + } + + /// @dev Validates that `_tokenOwner` owns and has approved Marketplace to transfer NFTs. + function _validateOwnershipAndApproval( + address _tokenOwner, + address _assetContract, + uint256 _tokenId, + uint256 _quantity, + TokenType _tokenType + ) internal view returns (bool isValid) { + address market = address(this); + + if (_tokenType == TokenType.ERC1155) { + isValid = + IERC1155(_assetContract).balanceOf(_tokenOwner, _tokenId) >= _quantity && + IERC1155(_assetContract).isApprovedForAll(_tokenOwner, market); + } else if (_tokenType == TokenType.ERC721) { + address owner; + address operator; + + // failsafe for reverts in case of non-existent tokens + try IERC721(_assetContract).ownerOf(_tokenId) returns (address _owner) { + owner = _owner; + + // Nesting the approval check inside this try block, to run only if owner check doesn't revert. + // If the previous check for owner fails, then the return value will always evaluate to false. + try IERC721(_assetContract).getApproved(_tokenId) returns (address _operator) { + operator = _operator; + } catch {} + } catch {} + + isValid = + owner == _tokenOwner && + (operator == market || IERC721(_assetContract).isApprovedForAll(_tokenOwner, market)); + } + } + + /// @dev Validates that `_tokenOwner` owns and has approved Markeplace to transfer the appropriate amount of currency + function _validateERC20BalAndAllowance(address _tokenOwner, address _currency, uint256 _amount) internal view { + require( + IERC20(_currency).balanceOf(_tokenOwner) >= _amount && + IERC20(_currency).allowance(_tokenOwner, address(this)) >= _amount, + "!BAL20" + ); + } + + /// @dev Transfers tokens listed for sale in a direct or auction listing. + function _transferListingTokens(address _from, address _to, uint256 _quantity, Listing memory _listing) internal { + if (_listing.tokenType == TokenType.ERC1155) { + IERC1155(_listing.assetContract).safeTransferFrom(_from, _to, _listing.tokenId, _quantity, ""); + } else if (_listing.tokenType == TokenType.ERC721) { + IERC721(_listing.assetContract).safeTransferFrom(_from, _to, _listing.tokenId, ""); + } + } + + /// @dev Pays out stakeholders in a sale. + function _payout( + address _payer, + address _payee, + address _currencyToUse, + uint256 _totalPayoutAmount, + Listing memory _listing + ) internal { + uint256 amountRemaining; + uint256 platformFeeCut; + + // Payout platform fee + { + // Descrease platform fee for mint token + if (_currencyToUse == mintTokenAddress) { + platformFeeCut = (_totalPayoutAmount * platformFeeBpsMint) / MAX_BPS; + } else { + platformFeeCut = (_totalPayoutAmount * platformFeeBps) / MAX_BPS; + } + + // Transfer platform fee + CurrencyTransferLib.transferCurrency(_currencyToUse, _payer, platformFeeRecipient, platformFeeCut); + + amountRemaining = _totalPayoutAmount - platformFeeCut; + } + + // Payout royalties + { + // Get royalty recipients and amounts + (address royaltyRecipient, uint256 royaltyAmount) = processRoyalty( + _listing.assetContract, + _listing.tokenId, + _totalPayoutAmount + ); + + if (royaltyAmount > 0) { + // Check payout amount remaining is enough to cover royalty payment + require(amountRemaining >= royaltyAmount, "fees exceed the price"); + + // Transfer royalty + CurrencyTransferLib.transferCurrency(_currencyToUse, _payer, royaltyRecipient, royaltyAmount); + + amountRemaining = amountRemaining - royaltyAmount; + + emit MintraRoyaltyTransfered( + _listing.assetContract, + _listing.tokenId, + _listing.listingId, + _totalPayoutAmount, + royaltyAmount, + platformFeeCut, + royaltyRecipient, + _currencyToUse + ); + } + } + + // Distribute price to token owner + CurrencyTransferLib.transferCurrency(_currencyToUse, _payer, _payee, amountRemaining); + } + + function processRoyalty( + address _tokenAddress, + uint256 _tokenId, + uint256 _price + ) internal view returns (address royaltyReceiver, uint256 royaltyAmount) { + // Check if collection has royalty using ERC2981 + if (isERC2981(_tokenAddress)) { + (royaltyReceiver, royaltyAmount) = IERC2981(_tokenAddress).royaltyInfo(_tokenId, _price); + } else { + royaltyAmount = (_price * royalties[_tokenAddress].basisPoints) / 10000; + royaltyReceiver = royalties[_tokenAddress].receiver; + } + + return (royaltyReceiver, royaltyAmount); + } + + /** + * @notice This function checks if a given contract is ERC2981 compliant + * @dev This function is called internally and cannot be accessed outside the contract + * @param _contract The address of the contract to check + * @return A boolean indicating whether the contract is ERC2981 compliant or not + */ + function isERC2981(address _contract) internal view returns (bool) { + try IERC2981(_contract).royaltyInfo(0, 0) returns (address, uint256) { + return true; + } catch { + return false; + } + } + + /// @dev Returns the DirectListings storage. + function _directListingsStorage() internal pure returns (DirectListingsStorage.Data storage data) { + data = DirectListingsStorage.data(); + } + + /** + * @notice Update the market fee percentage + * @dev Updates the market fee percentage to a new value + * @param _platformFeeBps New value for the market fee percentage + */ + function setPlatformFeeBps(uint256 _platformFeeBps) public onlyWizard { + require(_platformFeeBps <= 369, "Fee not in range"); + + platformFeeBps = _platformFeeBps; + + emit PlatformFeeUpdated(_platformFeeBps); + } +} diff --git a/package.json b/package.json index 8d19c33dd..4598db41b 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "prettier:list-different": "prettier --config .prettierrc --plugin=prettier-plugin-solidity --list-different '**/*.sol'", "prettier:contracts": "prettier --config .prettierrc --plugin=prettier-plugin-solidity --list-different '{contracts,src}/**/*.sol'", "test": "forge test", + "coverage_mintra": "forge coverage --match-contract MintraDirectListingsLogicStandaloneTest --report lcov && genhtml lcov.info --output-dir coverage", "typechain": "typechain --target ethers-v5 --out-dir ./typechain artifacts_forge/**/*.json", "build": "yarn clean && yarn compile", "forge:build": "forge build", diff --git a/src/test/marketplace/MintraDirectListings.t.sol b/src/test/marketplace/MintraDirectListings.t.sol new file mode 100644 index 000000000..e123ed446 --- /dev/null +++ b/src/test/marketplace/MintraDirectListings.t.sol @@ -0,0 +1,2348 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test helper imports +import "../utils/BaseTest.sol"; + +// Test contracts and interfaces +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { MarketplaceV3, IPlatformFee } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; +import { MockRoyaltyEngineV1 } from "../mocks/MockRoyaltyEngineV1.sol"; + +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; +import { MintraDirectListings } from "contracts/prebuilts/marketplace/direct-listings/MintraDirectListings.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; +import { MockERC721Ownable } from "../mocks/MockERC721Ownable.sol"; + +contract MintraDirectListingsTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + address public wizard; + address public collectionOwner; + + MintraDirectListings public mintraDirectListingsLogicStandalone; + MockERC721Ownable public erc721Ownable; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + wizard = getActor(4); + collectionOwner = getActor(5); + + // Deploy implementation. + mintraDirectListingsLogicStandalone = new MintraDirectListings( + address(weth), + address(erc20Aux), + address(platformFeeRecipient), + address(wizard) + ); + marketplace = address(mintraDirectListingsLogicStandalone); + + vm.prank(collectionOwner); + erc721Ownable = new MockERC721Ownable(); + + //vm.prank(marketplaceDeployer); + + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupERC721BalanceForSeller(address _seller, uint256 _numOfTokens) private { + erc721.mint(_seller, _numOfTokens); + } + + function test_state_initial() public { + uint256 totalListings = MintraDirectListings(marketplace).totalListings(); + assertEq(totalListings, 0); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_getValidListings_burnListedTokens() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + MintraDirectListings(marketplace).createListing(listingParams); + + // Total listings incremented + assertEq(MintraDirectListings(marketplace).totalListings(), 1); + + // burn listed token + vm.prank(seller); + erc721.burn(0); + + vm.warp(150); + // Fetch listing and verify state. + uint256 totalListings = MintraDirectListings(marketplace).totalListings(); + assertEq(MintraDirectListings(marketplace).getAllValidListings(0, totalListings - 1).length, 0); + } + + function test_state_approvedCurrencies() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParams) = _setup_updateListing(0); + address currencyToApprove = address(erc20); // same currency as main listing + uint256 pricePerTokenForCurrency = 2 ether; + + // Seller approves currency for listing. + vm.prank(seller); + vm.expectRevert("Marketplace: approving listing currency with different price."); + MintraDirectListings(marketplace).approveCurrencyForListing( + listingId, + currencyToApprove, + pricePerTokenForCurrency + ); + + // change currency + currencyToApprove = NATIVE_TOKEN; + + vm.prank(seller); + MintraDirectListings(marketplace).approveCurrencyForListing( + listingId, + currencyToApprove, + pricePerTokenForCurrency + ); + + assertEq( + MintraDirectListings(marketplace).isCurrencyApprovedForListing(listingId, NATIVE_TOKEN), + true + ); + assertEq( + MintraDirectListings(marketplace).currencyPriceForListing(listingId, NATIVE_TOKEN), + pricePerTokenForCurrency + ); + + // should revert when updating listing with an approved currency but different price + listingParams.currency = NATIVE_TOKEN; + vm.prank(seller); + vm.expectRevert("Marketplace: price different from approved price"); + MintraDirectListings(marketplace).updateListing(listingId, listingParams); + + // change listingParams.pricePerToken to approved price + listingParams.pricePerToken = pricePerTokenForCurrency; + vm.prank(seller); + MintraDirectListings(marketplace).updateListing(listingId, listingParams); + } + + /*/////////////////////////////////////////////////////////////// + Royalty Tests (incl Royalty Engine / Registry) + //////////////////////////////////////////////////////////////*/ + + function _setupListingForRoyaltyTests(address erc721TokenAddress) private returns (uint256 listingId) { + // Sample listing parameters. + address assetContract = erc721TokenAddress; + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 100 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = false; + + // Approve Marketplace to transfer token. + vm.prank(seller); + IERC721(erc721TokenAddress).setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + listingId = MintraDirectListings(marketplace).createListing(listingParams); + } + + function _buyFromListingForRoyaltyTests(uint256 listingId) private returns (uint256 totalPrice) { + IDirectListings.Listing memory listing = MintraDirectListings(marketplace).getListing(listingId); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + totalPrice = pricePerToken * quantityToBuy; + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + function test_noRoyaltyEngine_defaultERC2981Token() public { + // create token with ERC2981 + address royaltyRecipient = address(0x12345); + uint128 royaltyBps = 10; + uint256 platformFeeBps = MintraDirectListings(marketplace).platformFeeBps(); + ERC721Base nft2981 = new ERC721Base(address(0x12345), "NFT 2981", "NFT2981", royaltyRecipient, royaltyBps); + vm.prank(address(0x12345)); + nft2981.mintTo(seller, ""); + + // 1. ========= Create listing ========= + + uint256 listingId = _setupListingForRoyaltyTests(address(nft2981)); + + // 2. ========= Buy from listing ========= + + uint256 totalPrice = _buyFromListingForRoyaltyTests(listingId); + + // 3. ======== Check balances after royalty payments ======== + + { + uint256 platforfee = (platformFeeBps * totalPrice) / 10_000; + uint256 royaltyAmount = (royaltyBps * totalPrice) / 10_000; + + assertBalERC20Eq(address(erc20), platformFeeRecipient, platforfee); + + // Royalty recipient receives correct amounts + assertBalERC20Eq(address(erc20), royaltyRecipient, royaltyAmount); + + // Seller gets total price minus royalty amount minus platform fee + assertBalERC20Eq(address(erc20), seller, totalPrice - royaltyAmount - platforfee); + } + } + + function test_revert_mintra_native_royalty_feesExceedTotalPrice() public { + // Set native royalty too high + vm.prank(collectionOwner); + mintraDirectListingsLogicStandalone.createOrUpdateRoyalty(address(erc721Ownable), 10000, factoryAdmin); + + // 1. ========= Create listing ========= + erc721Ownable.mint(seller, 1); + uint256 listingId = _setupListingForRoyaltyTests(address(erc721Ownable)); + + // 2. ========= Buy from listing ========= + IDirectListings.Listing memory listing = MintraDirectListings(marketplace).getListing(listingId); + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.expectRevert("fees exceed the price"); + vm.prank(buyer); + + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + function test_revert_erc2981_royalty_feesExceedTotalPrice() public { + // Set erc2981 royalty too high + ERC721Base nft2981 = new ERC721Base(address(0x12345), "NFT 2981", "NFT2981", royaltyRecipient, 10000); + + // 1. ========= Create listing ========= + vm.prank(address(0x12345)); + nft2981.mintTo(seller, ""); + uint256 listingId = _setupListingForRoyaltyTests(address(nft2981)); + + // 2. ========= Buy from listing ========= + IDirectListings.Listing memory listing = MintraDirectListings(marketplace).getListing(listingId); + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.expectRevert("fees exceed the price"); + vm.prank(buyer); + + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + /*/////////////////////////////////////////////////////////////// + Create listing + //////////////////////////////////////////////////////////////*/ + + function createListing_1155(uint256 tokenId, uint256 totalListings) private returns (uint256 listingId) { + // Sample listing parameters. + address assetContract = address(erc1155); + uint256 quantity = 2; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = false; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + erc1155.mint(seller, tokenId, quantity, ""); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + + uint256[] memory amounts = new uint256[](1); + amounts[0] = quantity; + + assertBalERC1155Eq(address(erc1155), seller, tokenIds, amounts); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + listingId = MintraDirectListings(marketplace).createListing(listingParams); + + // Test consequent state of the contract. + + // Seller is still owner of token. + assertBalERC1155Eq(address(erc1155), seller, tokenIds, amounts); + + // Total listings incremented + assertEq(MintraDirectListings(marketplace).totalListings(), totalListings); + + // Fetch listing and verify state. + IDirectListings.Listing memory listing = MintraDirectListings(marketplace).getListing(listingId); + + assertEq(listing.listingId, listingId); + assertEq(listing.listingCreator, seller); + assertEq(listing.assetContract, assetContract); + assertEq(listing.tokenId, tokenId); + assertEq(listing.quantity, quantity); + assertEq(listing.currency, currency); + assertEq(listing.pricePerToken, pricePerToken); + assertEq(listing.startTimestamp, startTimestamp); + assertEq(listing.endTimestamp, endTimestamp); + assertEq(listing.reserved, reserved); + assertEq(uint256(listing.tokenType), uint256(IDirectListings.TokenType.ERC1155)); + + return listingId; + } + + function test_state_createListing() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + uint256 listingId = MintraDirectListings(marketplace).createListing(listingParams); + + // Test consequent state of the contract. + + // Seller is still owner of token. + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Total listings incremented + assertEq(MintraDirectListings(marketplace).totalListings(), 1); + + // Fetch listing and verify state. + IDirectListings.Listing memory listing = MintraDirectListings(marketplace).getListing(listingId); + + assertEq(listing.listingId, listingId); + assertEq(listing.listingCreator, seller); + assertEq(listing.assetContract, assetContract); + assertEq(listing.tokenId, tokenId); + assertEq(listing.quantity, quantity); + assertEq(listing.currency, currency); + assertEq(listing.pricePerToken, pricePerToken); + assertEq(listing.startTimestamp, startTimestamp); + assertEq(listing.endTimestamp, endTimestamp); + assertEq(listing.reserved, reserved); + assertEq(uint256(listing.tokenType), uint256(IDirectListings.TokenType.ERC721)); + } + + function test_state_createListing_start_time_in_past() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + + vm.warp(10000); // Set the timestop for block 1 to 10000 + + uint256 expectedStartTimestamp = 10000; + uint256 expectedEndTimestamp = type(uint128).max; + // Set the start time to be at a timestamp in the past + uint128 startTimestamp = uint128(block.timestamp) - 1000; + + uint128 endTimestamp = type(uint128).max; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + uint256 listingId = MintraDirectListings(marketplace).createListing(listingParams); + + // Test consequent state of the contract. + + // Seller is still owner of token. + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Total listings incremented + assertEq(MintraDirectListings(marketplace).totalListings(), 1); + + // Fetch listing and verify state. + IDirectListings.Listing memory listing = MintraDirectListings(marketplace).getListing(listingId); + + assertEq(listing.listingId, listingId); + assertEq(listing.listingCreator, seller); + assertEq(listing.assetContract, assetContract); + assertEq(listing.tokenId, tokenId); + assertEq(listing.quantity, quantity); + assertEq(listing.currency, currency); + assertEq(listing.pricePerToken, pricePerToken); + assertEq(listing.startTimestamp, expectedStartTimestamp); + assertEq(listing.endTimestamp, expectedEndTimestamp); + assertEq(listing.reserved, reserved); + assertEq(uint256(listing.tokenType), uint256(IDirectListings.TokenType.ERC721)); + } + + function test_revert_createListing_notOwnerOfListedToken() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Don't mint to 'token to be listed' to the seller. + address someWallet = getActor(1000); + _setupERC721BalanceForSeller(someWallet, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), someWallet, tokenIds); + assertIsNotOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(someWallet); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MintraDirectListings(marketplace).createListing(listingParams); + } + + function test_revert_createListing_notApprovedMarketplaceToTransferToken() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Don't approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, false); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MintraDirectListings(marketplace).createListing(listingParams); + } + + function test_revert_createListing_listingZeroQuantity() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 0; // Listing ZERO quantity + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: listing zero quantity."); + MintraDirectListings(marketplace).createListing(listingParams); + } + + function test_revert_createListing_listingInvalidQuantity() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 2; // Listing more than `1` quantity + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: listing invalid quantity."); + MintraDirectListings(marketplace).createListing(listingParams); + } + + function test_revert_createListing_invalidStartTimestamp() public { + uint256 blockTimestamp = 100 minutes; + // Set block.timestamp + vm.warp(blockTimestamp); + + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = uint128(blockTimestamp - 61 minutes); // start time is less than block timestamp. + uint128 endTimestamp = uint128(startTimestamp + 1); + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid startTimestamp."); + MintraDirectListings(marketplace).createListing(listingParams); + } + + function test_revert_createListing_invalidEndTimestamp() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = uint128(startTimestamp - 1); // End timestamp is less than start timestamp. + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: endTimestamp not greater than startTimestamp."); + MintraDirectListings(marketplace).createListing(listingParams); + } + + function test_revert_createListing_listingNonERC721OrERC1155Token() public { + // Sample listing parameters. + address assetContract = address(erc20); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.expectRevert("Marketplace: listed token must be ERC1155 or ERC721."); + MintraDirectListings(marketplace).createListing(listingParams); + } + + /*/////////////////////////////////////////////////////////////// + Update listing + //////////////////////////////////////////////////////////////*/ + + function _setup_updateListing( + uint256 tokenId + ) private returns (uint256 listingId, IDirectListings.ListingParameters memory listingParams) { + // listing parameters. + address assetContract = address(erc721); + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + listingId = MintraDirectListings(marketplace).createListing(listingParams); + } + + function test_state_updateListing() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(0); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + listingParamsToUpdate.pricePerToken = 2 ether; + + vm.prank(seller); + MintraDirectListings(marketplace).updateListing(listingId, listingParamsToUpdate); + + // Test consequent state of the contract. + + // Seller is still owner of token. + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Total listings not incremented on update. + assertEq(MintraDirectListings(marketplace).totalListings(), 1); + + // Fetch listing and verify state. + IDirectListings.Listing memory listing = MintraDirectListings(marketplace).getListing(listingId); + + assertEq(listing.listingId, listingId); + assertEq(listing.listingCreator, seller); + assertEq(listing.assetContract, listingParamsToUpdate.assetContract); + assertEq(listing.tokenId, 0); + assertEq(listing.quantity, listingParamsToUpdate.quantity); + assertEq(listing.currency, listingParamsToUpdate.currency); + assertEq(listing.pricePerToken, listingParamsToUpdate.pricePerToken); + assertEq(listing.startTimestamp, listingParamsToUpdate.startTimestamp); + assertEq(listing.endTimestamp, listingParamsToUpdate.endTimestamp); + assertEq(listing.reserved, listingParamsToUpdate.reserved); + assertEq(uint256(listing.tokenType), uint256(IDirectListings.TokenType.ERC721)); + } + + function test_state_updateListing_start_time_in_past() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(0); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + listingParamsToUpdate.pricePerToken = 2 ether; + + // Update the start time of the listing + uint256 expectedStartTimestamp = block.timestamp + 10; + uint256 expectedEndTimestamp = type(uint128).max; + + listingParamsToUpdate.startTimestamp = uint128(block.timestamp); + listingParamsToUpdate.endTimestamp = type(uint128).max; + vm.warp(block.timestamp + 10); // Set the timestamp 10 seconds in the future + + vm.prank(seller); + MintraDirectListings(marketplace).updateListing(listingId, listingParamsToUpdate); + + // Test consequent state of the contract. + + // Seller is still owner of token. + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Total listings not incremented on update. + assertEq(MintraDirectListings(marketplace).totalListings(), 1); + + // Fetch listing and verify state. + IDirectListings.Listing memory listing = MintraDirectListings(marketplace).getListing(listingId); + + assertEq(listing.listingId, listingId); + assertEq(listing.listingCreator, seller); + assertEq(listing.assetContract, listingParamsToUpdate.assetContract); + assertEq(listing.tokenId, 0); + assertEq(listing.quantity, listingParamsToUpdate.quantity); + assertEq(listing.currency, listingParamsToUpdate.currency); + assertEq(listing.pricePerToken, listingParamsToUpdate.pricePerToken); + assertEq(listing.startTimestamp, expectedStartTimestamp); + assertEq(listing.endTimestamp, expectedEndTimestamp); + assertEq(listing.reserved, listingParamsToUpdate.reserved); + assertEq(uint256(listing.tokenType), uint256(IDirectListings.TokenType.ERC721)); + } + + function test_revert_updateListing_notListingCreator() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(0); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + address notSeller = getActor(1000); // Someone other than the seller calls update. + vm.prank(notSeller); + vm.expectRevert("Marketplace: not listing creator."); + MintraDirectListings(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_notOwnerOfListedToken() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(0); + + // Mint MORE ERC721 tokens but NOT to seller. A new tokenId will be listed. + address notSeller = getActor(1000); + _setupERC721BalanceForSeller(notSeller, 1); + + // Approve Marketplace to transfer token. + vm.prank(notSeller); + erc721.setApprovalForAll(marketplace, true); + + // Transfer away owned token. + vm.prank(seller); + erc721.transferFrom(seller, address(0x1234), 0); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MintraDirectListings(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_notApprovedMarketplaceToTransferToken() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(0); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Don't approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, false); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MintraDirectListings(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_listingZeroQuantity() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(0); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + listingParamsToUpdate.quantity = 0; // Listing zero quantity + + vm.prank(seller); + vm.expectRevert("Marketplace: listing zero quantity."); + MintraDirectListings(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_listingInvalidQuantity() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(0); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + listingParamsToUpdate.quantity = 2; // Listing more than `1` of the ERC721 token + + vm.prank(seller); + vm.expectRevert("Marketplace: listing invalid quantity."); + MintraDirectListings(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_listingNonERC721OrERC1155Token() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(0); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + listingParamsToUpdate.assetContract = address(erc20); // Listing non ERC721 / ERC1155 token. + + vm.prank(seller); + vm.expectRevert("Marketplace: listed token must be ERC1155 or ERC721."); + MintraDirectListings(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_invalidStartTimestamp() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(0); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + uint128 currentStartTimestamp = listingParamsToUpdate.startTimestamp; + listingParamsToUpdate.startTimestamp = currentStartTimestamp - 1; // Retroactively decreasing startTimestamp. + + vm.warp(currentStartTimestamp + 50); + vm.prank(seller); + vm.expectRevert("Marketplace: listing already active."); + MintraDirectListings(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_invalidEndTimestamp() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(0); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + uint128 currentStartTimestamp = listingParamsToUpdate.startTimestamp; + listingParamsToUpdate.endTimestamp = currentStartTimestamp - 1; // End timestamp less than startTimestamp + + vm.prank(seller); + vm.expectRevert("Marketplace: endTimestamp not greater than startTimestamp."); + MintraDirectListings(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + /*/////////////////////////////////////////////////////////////// + Cancel listing + //////////////////////////////////////////////////////////////*/ + + function _setup_cancelListing( + uint256 tokenId + ) private returns (uint256 listingId, IDirectListings.Listing memory listing) { + (listingId, ) = _setup_updateListing(tokenId); + listing = MintraDirectListings(marketplace).getListing(listingId); + } + + function test_state_cancelListing() public { + (uint256 listingId, IDirectListings.Listing memory existingListingAtId) = _setup_cancelListing(0); + + // Verify existing listing at `listingId` + assertEq(existingListingAtId.assetContract, address(erc721)); + + vm.prank(seller); + MintraDirectListings(marketplace).cancelListing(listingId); + + // status should be `CANCELLED` + IDirectListings.Listing memory cancelledListing = MintraDirectListings(marketplace).getListing( + listingId + ); + assertTrue(cancelledListing.status == IDirectListings.Status.CANCELLED); + } + + function test_revert_cancelListing_notListingCreator() public { + (uint256 listingId, IDirectListings.Listing memory existingListingAtId) = _setup_cancelListing(0); + + // Verify existing listing at `listingId` + assertEq(existingListingAtId.assetContract, address(erc721)); + + address notSeller = getActor(1000); + vm.prank(notSeller); + vm.expectRevert("Marketplace: not listing creator."); + MintraDirectListings(marketplace).cancelListing(listingId); + } + + function test_revert_cancelListing_nonExistentListing() public { + _setup_cancelListing(0); + + // Verify no listing exists at `nexListingId` + uint256 nextListingId = MintraDirectListings(marketplace).totalListings(); + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid listing."); + MintraDirectListings(marketplace).cancelListing(nextListingId); + } + + /*/////////////////////////////////////////////////////////////// + Approve buyer for listing + //////////////////////////////////////////////////////////////*/ + + function _setup_approveBuyerForListing(uint256 tokenId) private returns (uint256 listingId) { + (listingId, ) = _setup_updateListing(tokenId); + } + + function test_state_approveBuyerForListing() public { + uint256 listingId = _setup_approveBuyerForListing(0); + bool toApprove = true; + + assertEq(MintraDirectListings(marketplace).getListing(listingId).reserved, true); + + // Seller approves buyer for reserved listing. + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, toApprove); + + assertEq(MintraDirectListings(marketplace).isBuyerApprovedForListing(listingId, buyer), true); + } + + function test_revert_approveBuyerForListing_notListingCreator() public { + uint256 listingId = _setup_approveBuyerForListing(0); + bool toApprove = true; + + assertEq(MintraDirectListings(marketplace).getListing(listingId).reserved, true); + + // Someone other than the seller approves buyer for reserved listing. + address notSeller = getActor(1000); + vm.prank(notSeller); + vm.expectRevert("Marketplace: not listing creator."); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, toApprove); + } + + function test_revert_approveBuyerForListing_listingNotReserved() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(0); + bool toApprove = true; + + assertEq(MintraDirectListings(marketplace).getListing(listingId).reserved, true); + + listingParamsToUpdate.reserved = false; + + vm.prank(seller); + MintraDirectListings(marketplace).updateListing(listingId, listingParamsToUpdate); + + assertEq(MintraDirectListings(marketplace).getListing(listingId).reserved, false); + + // Seller approves buyer for reserved listing. + vm.prank(seller); + vm.expectRevert("Marketplace: listing not reserved."); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, toApprove); + } + + /*/////////////////////////////////////////////////////////////// + Approve currency for listing + //////////////////////////////////////////////////////////////*/ + + function _setup_approveCurrencyForListing(uint256 tokenId) private returns (uint256 listingId) { + (listingId, ) = _setup_updateListing(tokenId); + } + + function test_state_approveCurrencyForListing() public { + uint256 listingId = _setup_approveCurrencyForListing(0); + address currencyToApprove = NATIVE_TOKEN; + uint256 pricePerTokenForCurrency = 2 ether; + + // Seller approves buyer for reserved listing. + vm.prank(seller); + MintraDirectListings(marketplace).approveCurrencyForListing( + listingId, + currencyToApprove, + pricePerTokenForCurrency + ); + + assertEq( + MintraDirectListings(marketplace).isCurrencyApprovedForListing(listingId, NATIVE_TOKEN), + true + ); + assertEq( + MintraDirectListings(marketplace).currencyPriceForListing(listingId, NATIVE_TOKEN), + pricePerTokenForCurrency + ); + } + + function test_revert_approveCurrencyForListing_notListingCreator() public { + uint256 listingId = _setup_approveCurrencyForListing(0); + address currencyToApprove = NATIVE_TOKEN; + uint256 pricePerTokenForCurrency = 2 ether; + + // Someone other than seller approves buyer for reserved listing. + address notSeller = getActor(1000); + vm.prank(notSeller); + vm.expectRevert("Marketplace: not listing creator."); + MintraDirectListings(marketplace).approveCurrencyForListing( + listingId, + currencyToApprove, + pricePerTokenForCurrency + ); + } + + function test_revert_approveCurrencyForListing_reApprovingMainCurrency() public { + uint256 listingId = _setup_approveCurrencyForListing(0); + address currencyToApprove = MintraDirectListings(marketplace).getListing(listingId).currency; + uint256 pricePerTokenForCurrency = 2 ether; + + // Seller approves buyer for reserved listing. + vm.prank(seller); + vm.expectRevert("Marketplace: approving listing currency with different price."); + MintraDirectListings(marketplace).approveCurrencyForListing( + listingId, + currencyToApprove, + pricePerTokenForCurrency + ); + } + + /*/////////////////////////////////////////////////////////////// + Buy from listing + //////////////////////////////////////////////////////////////*/ + + function _setup_buyFromListing( + uint256 tokenId + ) private returns (uint256 listingId, IDirectListings.Listing memory listing) { + (listingId, ) = _setup_updateListing(tokenId); + listing = MintraDirectListings(marketplace).getListing(listingId); + } + + function test_state_buyFromListing_with_mint_token() public { + uint256 listingId = _createListing(seller, address(erc20Aux)); + IDirectListings.Listing memory listing = MintraDirectListings(marketplace).getListing(listingId); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + uint256 platformFeeBpsMint = MintraDirectListings(marketplace).platformFeeBpsMint(); + uint256 platformFee = (totalPrice * platformFeeBpsMint) / 10000; + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20Aux.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20Aux), buyer, totalPrice); + assertBalERC20Eq(address(erc20Aux), seller, 0); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20Aux.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + + { + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + // Verify that buyer is owner of listed tokens, post-sale. + assertIsOwnerERC721(address(erc721), buyer, tokenIds); + assertIsNotOwnerERC721(address(erc721), seller, tokenIds); + + // Verify seller is paid total price. + assertBalERC20Eq(address(erc20Aux), buyer, 0); + assertBalERC20Eq(address(erc20Aux), seller, totalPrice - platformFee); + + if (quantityToBuy == listing.quantity) { + // Verify listing status is `COMPLETED` if listing tokens are all bought. + IDirectListings.Listing memory completedListing = MintraDirectListings(marketplace) + .getListing(listingId); + assertTrue(completedListing.status == IDirectListings.Status.COMPLETED); + } + } + + function test_state_buyFromListing_721() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(0); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + uint256 platformFeeBps = MintraDirectListings(marketplace).platformFeeBps(); + uint256 platformFee = (totalPrice * platformFeeBps) / 10000; + + // Seller approves buyer for listing + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + + { + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + // Verify that buyer is owner of listed tokens, post-sale. + assertIsOwnerERC721(address(erc721), buyer, tokenIds); + assertIsNotOwnerERC721(address(erc721), seller, tokenIds); + + // Verify seller is paid total price. + assertBalERC20Eq(address(erc20), buyer, 0); + assertBalERC20Eq(address(erc20), seller, totalPrice - platformFee); + + if (quantityToBuy == listing.quantity) { + // Verify listing status is `COMPLETED` if listing tokens are all bought. + IDirectListings.Listing memory completedListing = MintraDirectListings(marketplace) + .getListing(listingId); + assertTrue(completedListing.status == IDirectListings.Status.COMPLETED); + } + } + + function test_state_buyFromListing_multi_721() public { + vm.prank(seller); + (uint256 listingIdOne, IDirectListings.Listing memory listingOne) = _setup_buyFromListing(0); + vm.prank(seller); + (uint256 listingIdTwo, IDirectListings.Listing memory listingTwo) = _setup_buyFromListing(1); + + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingIdOne, buyer, true); + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingIdTwo, buyer, true); + + address buyFor = buyer; + uint256 quantityToBuy = listingOne.quantity; + address currency = listingOne.currency; + uint256 pricePerToken = listingOne.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + uint256 platformFeeBps = MintraDirectListings(marketplace).platformFeeBps(); + uint256 platformFee = (totalPrice * platformFeeBps) / 10000; + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice + totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice + totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice + totalPrice); + + // Buy tokens from listing. + vm.warp(listingTwo.startTimestamp); + { + uint256[] memory listingIdArray = new uint256[](2); + listingIdArray[0] = listingIdOne; + listingIdArray[1] = listingIdTwo; + + address[] memory buyForArray = new address[](2); + buyForArray[0] = buyFor; + buyForArray[1] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](2); + quantityToBuyArray[0] = quantityToBuy; + quantityToBuyArray[1] = quantityToBuy; + + address[] memory currencyArray = new address[](2); + currencyArray[0] = currency; + currencyArray[1] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](2); + expectedTotalPriceArray[0] = totalPrice; + expectedTotalPriceArray[1] = totalPrice; + + vm.prank(buyer); + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + // Verify that buyer is owner of listed tokens, post-sale. + assertIsOwnerERC721(address(erc721), buyer, tokenIds); + assertIsNotOwnerERC721(address(erc721), seller, tokenIds); + + // Verify seller is paid total price. + assertBalERC20Eq(address(erc20), buyer, 0); + uint256 sellerPayout = totalPrice + totalPrice - platformFee - platformFee; + assertBalERC20Eq(address(erc20), seller, sellerPayout); + } + + function test_state_buyFromListing_1155() public { + // Create the listing + uint256 listingId = createListing_1155(0, 1); + + IDirectListings.Listing memory listing = MintraDirectListings(marketplace).getListing(listingId); + + address buyFor = buyer; + uint256 tokenId = listing.tokenId; + uint256 quantity = listing.quantity; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + uint256 platformFeeBps = MintraDirectListings(marketplace).platformFeeBps(); + uint256 platformFee = (totalPrice * platformFeeBps) / 10000; + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + + uint256[] memory amounts = new uint256[](1); + amounts[0] = quantity; + + assertBalERC1155Eq(address(erc1155), seller, tokenIds, amounts); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + { + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + // Verify that buyer is owner of listed tokens, post-sale. + assertBalERC1155Eq(address(erc1155), buyer, tokenIds, amounts); + + // Verify seller is paid total price. + assertBalERC20Eq(address(erc20), buyer, 0); + assertBalERC20Eq(address(erc20), seller, totalPrice - platformFee); + + if (quantityToBuy == listing.quantity) { + // Verify listing status is `COMPLETED` if listing tokens are all bought. + IDirectListings.Listing memory completedListing = MintraDirectListings(marketplace) + .getListing(listingId); + assertTrue(completedListing.status == IDirectListings.Status.COMPLETED); + } + } + + function test_state_buyFromListing_multi_1155() public { + vm.prank(seller); + uint256 listingIdOne = createListing_1155(0, 1); + IDirectListings.Listing memory listingOne = MintraDirectListings(marketplace).getListing( + listingIdOne + ); + + vm.prank(seller); + uint256 listingIdTwo = createListing_1155(1, 2); + IDirectListings.Listing memory listingTwo = MintraDirectListings(marketplace).getListing( + listingIdTwo + ); + + address buyFor = buyer; + uint256 quantityToBuy = listingOne.quantity; + address currency = listingOne.currency; + uint256 pricePerToken = listingOne.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + uint256 platformFeeBps = MintraDirectListings(marketplace).platformFeeBps(); + uint256 platformFee = (totalPrice * platformFeeBps) / 10000; + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + + uint256[] memory amounts = new uint256[](2); + amounts[0] = 2; + amounts[1] = 2; + + assertBalERC1155Eq(address(erc1155), seller, tokenIds, amounts); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice + totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice + totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice + totalPrice); + + // Buy tokens from listing. + vm.warp(listingTwo.startTimestamp); + { + uint256[] memory listingIdArray = new uint256[](2); + listingIdArray[0] = listingIdOne; + listingIdArray[1] = listingIdTwo; + + address[] memory buyForArray = new address[](2); + buyForArray[0] = buyFor; + buyForArray[1] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](2); + quantityToBuyArray[0] = quantityToBuy; + quantityToBuyArray[1] = quantityToBuy; + + address[] memory currencyArray = new address[](2); + currencyArray[0] = currency; + currencyArray[1] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](2); + expectedTotalPriceArray[0] = totalPrice; + expectedTotalPriceArray[1] = totalPrice; + + vm.prank(buyer); + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + // Verify that buyer is owner of listed tokens, post-sale. + assertBalERC1155Eq(address(erc1155), buyer, tokenIds, amounts); + + // Verify seller is paid total price. + assertBalERC20Eq(address(erc20), buyer, 0); + uint256 sellerPayout = totalPrice + totalPrice - platformFee - platformFee; + assertBalERC20Eq(address(erc20), seller, sellerPayout); + } + + function test_state_bulkBuyFromListing_nativeToken() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(0); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = NATIVE_TOKEN; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + uint256 platformFeeBps = MintraDirectListings(marketplace).platformFeeBps(); + uint256 platformFee = (totalPrice * platformFeeBps) / 10000; + + // Approve NATIVE_TOKEN for listing + vm.prank(seller); + MintraDirectListings(marketplace).approveCurrencyForListing(listingId, currency, pricePerToken); + + // Seller approves buyer for listing + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Deal requisite total price to buyer. + vm.deal(buyer, totalPrice); + uint256 buyerBalBefore = buyer.balance; + uint256 sellerBalBefore = seller.balance; + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + { + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing{ value: totalPrice }( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + // Verify that buyer is owner of listed tokens, post-sale. + assertIsOwnerERC721(address(erc721), buyer, tokenIds); + assertIsNotOwnerERC721(address(erc721), seller, tokenIds); + + // Verify seller is paid total price. + assertEq(buyer.balance, buyerBalBefore - totalPrice); + assertEq(seller.balance, sellerBalBefore + (totalPrice - platformFee)); + + if (quantityToBuy == listing.quantity) { + // Verify listing status is `COMPLETED` if listing tokens are all bought. + IDirectListings.Listing memory completedListing = MintraDirectListings(marketplace) + .getListing(listingId); + assertTrue(completedListing.status == IDirectListings.Status.COMPLETED); + } + } + + function test_revert_bulkBuyFromListing_nativeToken_incorrectValueSent() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(0); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = NATIVE_TOKEN; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Approve NATIVE_TOKEN for listing + vm.prank(seller); + MintraDirectListings(marketplace).approveCurrencyForListing(listingId, currency, pricePerToken); + + // Seller approves buyer for listing + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Deal requisite total price to buyer. + vm.deal(buyer, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("native token transfer failed"); + { + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing{ value: totalPrice - 1 }( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + } + + function test_revert_buyFromListing_unexpectedTotalPrice() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(0); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = NATIVE_TOKEN; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Approve NATIVE_TOKEN for listing + vm.prank(seller); + MintraDirectListings(marketplace).approveCurrencyForListing(listingId, currency, pricePerToken); + + // Seller approves buyer for listing + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Deal requisite total price to buyer. + vm.deal(buyer, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("Unexpected total price"); + + { + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice + 1; + + MintraDirectListings(marketplace).bulkBuyFromListing{ value: totalPrice - 1 }( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + } + + function test_revert_buyFromListing_invalidCurrency() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(0); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Seller approves buyer for listing + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + + assertEq(listing.currency, address(erc20)); + assertEq( + MintraDirectListings(marketplace).isCurrencyApprovedForListing(listingId, NATIVE_TOKEN), + false + ); + + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("Paying in invalid currency."); + + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = NATIVE_TOKEN; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + function test_revert_buyFromListing_buyerBalanceLessThanPrice() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(0); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Seller approves buyer for listing + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice - 1); // Buyer balance less than total price + assertBalERC20Eq(address(erc20), buyer, totalPrice - 1); + assertBalERC20Eq(address(erc20), seller, 0); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("!BAL20"); + + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + function test_revert_buyFromListing_notApprovedMarketplaceToTransferPrice() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(0); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Seller approves buyer for listing + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Don't approve marketplace to transfer currency + vm.prank(buyer); + erc20.approve(marketplace, 0); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("!BAL20"); + + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + function test_revert_buyFromListing_buyingZeroQuantity() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(0); + + address buyFor = buyer; + uint256 quantityToBuy = 0; // Buying zero quantity + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Seller approves buyer for listing + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Don't approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("Buying invalid quantity"); + + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + function test_revert_buyFromListing_buyingMoreQuantityThanListed() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(0); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity + 1; // Buying more than listed. + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Seller approves buyer for listing + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Don't approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("Buying invalid quantity"); + + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyFor; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = quantityToBuy; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = currency; + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = totalPrice; + + MintraDirectListings(marketplace).bulkBuyFromListing( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + } + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + function test_getAllListing() public { + // Create the listing + createListing_1155(0, 1); + + IDirectListings.Listing[] memory listings = MintraDirectListings(marketplace).getAllListings( + 0, + 0 + ); + + assertEq(listings.length, 1); + + IDirectListings.Listing memory listing = listings[0]; + + assertEq(listing.assetContract, address(erc1155)); + assertEq(listing.tokenId, 0); + assertEq(listing.quantity, 2); + assertEq(listing.currency, address(erc20)); + assertEq(listing.pricePerToken, 1 ether); + assertEq(listing.startTimestamp, 100); + assertEq(listing.endTimestamp, 200); + assertEq(listing.reserved, false); + } + + function test_getAllValidListings() public { + // Create the listing + createListing_1155(0, 1); + + IDirectListings.Listing[] memory listingsAll = MintraDirectListings(marketplace).getAllListings( + 0, + 0 + ); + + assertEq(listingsAll.length, 1); + + vm.warp(listingsAll[0].startTimestamp); + IDirectListings.Listing[] memory listings = MintraDirectListings(marketplace) + .getAllValidListings(0, 0); + + assertEq(listings.length, 1); + + IDirectListings.Listing memory listing = listings[0]; + + assertEq(listing.assetContract, address(erc1155)); + assertEq(listing.tokenId, 0); + assertEq(listing.quantity, 2); + assertEq(listing.currency, address(erc20)); + assertEq(listing.pricePerToken, 1 ether); + assertEq(listing.startTimestamp, 100); + assertEq(listing.endTimestamp, 200); + assertEq(listing.reserved, false); + } + + function test_currencyPriceForListing_fail() public { + // Create the listing + createListing_1155(0, 1); + + vm.expectRevert("Currency not approved for listing"); + MintraDirectListings(marketplace).currencyPriceForListing(0, address(erc20Aux)); + } + + function _createListing(address _seller, address currency) private returns (uint256 listingId) { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = false; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(_seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), _seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(_seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(_seller); + listingId = MintraDirectListings(marketplace).createListing(listingParams); + } + + function test_audit_native_tokens_locked() public { + (uint256 listingId, IDirectListings.Listing memory existingListing) = _setup_buyFromListing(0); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingListing.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingListing.assetContract, address(erc721)); + + vm.warp(existingListing.startTimestamp); + + // No ether is locked in contract + assertEq(marketplace.balance, 0); + + // buy from listing + erc20.mint(buyer, 10 ether); + vm.deal(buyer, 1 ether); + + vm.prank(seller); + MintraDirectListings(marketplace).approveBuyerForListing(listingId, buyer, true); + + vm.startPrank(buyer); + erc20.approve(marketplace, 10 ether); + + vm.expectRevert("Incorrect PLS amount sent"); + + uint256[] memory listingIdArray = new uint256[](1); + listingIdArray[0] = listingId; + + address[] memory buyForArray = new address[](1); + buyForArray[0] = buyer; + + uint256[] memory quantityToBuyArray = new uint256[](1); + quantityToBuyArray[0] = 1; + + address[] memory currencyArray = new address[](1); + currencyArray[0] = address(erc20); + + uint256[] memory expectedTotalPriceArray = new uint256[](1); + expectedTotalPriceArray[0] = 1 ether; + + MintraDirectListings(marketplace).bulkBuyFromListing{ value: 1 ether }( + listingIdArray, + buyForArray, + quantityToBuyArray, + currencyArray, + expectedTotalPriceArray + ); + + vm.stopPrank(); + + // 1 ether is temporary locked in contract + assertEq(marketplace.balance, 0 ether); + } + + function test_set_platform_fee() public { + uint256 platformFeeBps = MintraDirectListings(marketplace).platformFeeBps(); + assertEq(platformFeeBps, 225); + + vm.prank(wizard); + MintraDirectListings(marketplace).setPlatformFeeBps(369); + + platformFeeBps = MintraDirectListings(marketplace).platformFeeBps(); + + assertEq(platformFeeBps, 369); + } + + function test_fuzz_set_platform_fee(uint256 platformFeeBps) public { + vm.assume(platformFeeBps <= 369); + + vm.prank(wizard); + MintraDirectListings(marketplace).setPlatformFeeBps(platformFeeBps); + + uint256 expectedPlatformFeeBps = MintraDirectListings(marketplace).platformFeeBps(); + + assertEq(expectedPlatformFeeBps, platformFeeBps); + } + + function test_set_platform_fee_fail() public { + vm.prank(wizard); + vm.expectRevert("Fee not in range"); + MintraDirectListings(marketplace).setPlatformFeeBps(1000); + } +} diff --git a/src/test/mocks/MockERC721Ownable.sol b/src/test/mocks/MockERC721Ownable.sol new file mode 100644 index 000000000..6bfa7d3c3 --- /dev/null +++ b/src/test/mocks/MockERC721Ownable.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract MockERC721Ownable is ERC721Burnable, Ownable { + uint256 public nextTokenIdToMint; + + constructor() ERC721("MockERC721Ownable", "MOCK") {} + + function mint(address _receiver, uint256 _amount) external { + uint256 tokenId = nextTokenIdToMint; + nextTokenIdToMint += _amount; + + for (uint256 i = 0; i < _amount; i += 1) { + _mint(_receiver, tokenId); + tokenId += 1; + } + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return super.supportsInterface(interfaceId); + } +}