diff --git a/src/test/marketplace/direct-listings/_payout/_payout.t.sol b/src/test/marketplace/direct-listings/_payout/_payout.t.sol new file mode 100644 index 000000000..3c9d167d9 --- /dev/null +++ b/src/test/marketplace/direct-listings/_payout/_payout.t.sol @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { PlatformFee } from "contracts/extension/PlatformFee.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; +import { MockRoyaltyEngineV1 } from "../../../mocks/MockRoyaltyEngineV1.sol"; + +contract PayoutTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + uint256 internal listingId = 0; + + // Events to test + + /// @notice Emitted when a listing is updated. + event UpdatedListing( + address indexed listingCreator, + uint256 indexed listingId, + address indexed assetContract, + IDirectListings.Listing listing + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), platformFeeRecipient, uint16(platformFeeBps)) + ) + ) + ); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100 minutes; + uint128 endTimestamp = 200 minutes; + bool reserved = false; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + address payable[] internal mockRecipients; + uint256[] internal mockAmounts; + MockRoyaltyEngineV1 internal royaltyEngine; + + function _setupRoyaltyEngine() private { + mockRecipients.push(payable(address(0x12345))); + mockRecipients.push(payable(address(0x56789))); + + mockAmounts.push(10 ether); + mockAmounts.push(15 ether); + + royaltyEngine = new MockRoyaltyEngineV1(mockRecipients, mockAmounts); + } + + 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 listingParameters = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + _listingId = DirectListingsLogic(marketplace).createListing(listingParameters); + } + + function _buyFromListingForRoyaltyTests(uint256 _listingId) private returns (uint256 totalPrice) { + IDirectListings.Listing memory listing = DirectListingsLogic(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); + DirectListingsLogic(marketplace).buyFromListing(_listingId, buyFor, quantityToBuy, currency, totalPrice); + } + + function test_payout_whenZeroRoyaltyRecipients() public { + // 1. ========= Create listing ========= + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + vm.stopPrank(); + + // 2. ========= Buy from listing ========= + + uint256 totalPrice = listingParams.pricePerToken; + + // 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(listingParams.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing( + listingId, + buyer, + listingParams.quantity, + listingParams.currency, + totalPrice + ); + + // 3. ======== Check balances after royalty payments ======== + + uint256 platformFees = (totalPrice * platformFeeBps) / 10_000; + + { + // Platform fee recipient receives correct amount + assertBalERC20Eq(address(erc20), platformFeeRecipient, platformFees); + + // Seller gets total price minus royalty amounts + assertBalERC20Eq(address(erc20), seller, totalPrice - platformFees); + } + } + + modifier whenNonZeroRoyaltyRecipients() { + _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + _; + } + + function test_payout_whenInsufficientFundsToPayRoyaltyAfterPlatformFeePayout() public whenNonZeroRoyaltyRecipients { + vm.prank(marketplaceDeployer); + PlatformFee(marketplace).setPlatformFeeInfo(platformFeeRecipient, 9999); // 99.99% fees + + // Mint the ERC721 tokens to seller. These tokens will be listed. + erc721.mint(seller, 1); + listingId = _setupListingForRoyaltyTests(address(erc721)); + + IDirectListings.Listing memory listing = DirectListingsLogic(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.prank(buyer); + vm.expectRevert("fees exceed the price"); + DirectListingsLogic(marketplace).buyFromListing(listingId, buyFor, quantityToBuy, currency, totalPrice); + } + + function test_payout_whenSufficientFundsToPayRoyaltyAfterPlatformFeePayout() public whenNonZeroRoyaltyRecipients { + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // 1. ========= Create listing ========= + + // Mint the ERC721 tokens to seller. These tokens will be listed. + erc721.mint(seller, 1); + listingId = _setupListingForRoyaltyTests(address(erc721)); + + // 2. ========= Buy from listing ========= + + uint256 totalPrice = _buyFromListingForRoyaltyTests(listingId); + + // 3. ======== Check balances after royalty payments ======== + + uint256 platformFees = (totalPrice * platformFeeBps) / 10_000; + + { + // Royalty recipients receive correct amounts + assertBalERC20Eq(address(erc20), mockRecipients[0], mockAmounts[0]); + assertBalERC20Eq(address(erc20), mockRecipients[1], mockAmounts[1]); + + // Platform fee recipient receives correct amount + assertBalERC20Eq(address(erc20), platformFeeRecipient, platformFees); + + // Seller gets total price minus royalty amounts + assertBalERC20Eq(address(erc20), seller, totalPrice - mockAmounts[0] - mockAmounts[1] - platformFees); + } + } +} diff --git a/src/test/marketplace/direct-listings/_payout/_payout.tree b/src/test/marketplace/direct-listings/_payout/_payout.tree new file mode 100644 index 000000000..3d09e5d13 --- /dev/null +++ b/src/test/marketplace/direct-listings/_payout/_payout.tree @@ -0,0 +1,17 @@ +function _payout( + address _payer, + address _payee, + address _currencyToUse, + uint256 _totalPayoutAmount, + Listing memory _listing +) +├── when there are zero royalty recipients ✅ +│ ├── it should transfer platform fee from payer to platform fee recipient +│ └── it should transfer remainder of currency from payer to payee +└── when there are non-zero royalty recipients + ├── when the total royalty payout exceeds remainder payout after having paid platform fee + │ └── it should revert ✅ + └── when the total royalty payout does not exceed remainder payout after having paid platform fee ✅ + ├── it should transfer platform fee from payer to platform fee recipient + ├── it should transfer royalty fee from payer to royalty recipients + └── it should transfer remainder of currency from payer to payee \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/_transferListingTokens/_transferListingTokens.t.sol b/src/test/marketplace/direct-listings/_transferListingTokens/_transferListingTokens.t.sol new file mode 100644 index 000000000..3673ef854 --- /dev/null +++ b/src/test/marketplace/direct-listings/_transferListingTokens/_transferListingTokens.t.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract MockTransferListingTokens is DirectListingsLogic { + constructor(address _nativeTokenWrapper) DirectListingsLogic(_nativeTokenWrapper) {} + + function transferListingTokens( + address _from, + address _to, + uint256 _quantity, + IDirectListings.Listing memory _listing + ) external { + _transferListingTokens(_from, _to, _quantity, _listing); + } +} + +contract TransferListingTokensTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public recipient; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + uint256 listingId_erc721 = 0; + uint256 listingId_erc1155 = 1; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + recipient = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + 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; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + // Mint 100 ERC1155 NFT to seller + erc1155.mint(seller, listingParams.tokenId, 100); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + + // Create listings + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.startPrank(seller); + + erc721.setApprovalForAll(marketplace, true); + erc1155.setApprovalForAll(marketplace, true); + + listingId_erc721 = DirectListingsLogic(marketplace).createListing(listingParams); + + listingParams.assetContract = address(erc1155); + listingParams.quantity = 100; + listingId_erc1155 = DirectListingsLogic(marketplace).createListing(listingParams); + + vm.stopPrank(); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `MockTransferListingTokens` + address directListings = address(new MockTransferListingTokens(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "MockTransferListingTokens", + metadataURI: "ipfs://MockTransferListingTokens", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](3); + extension_directListings.functions[0] = ExtensionFunction( + MockTransferListingTokens.transferListingTokens.selector, + "transferListingTokens(address,address,uint256,(uint256,uint256,uint256,uint256,uint128,uint128,address,address,address,uint8,uint8,bool))" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + extensions[0] = extension_directListings; + } + + function test_transferListingTokens_erc1155() public { + IDirectListings.Listing memory listing = DirectListingsLogic(marketplace).getListing(listingId_erc1155); + + assertEq(erc1155.balanceOf(seller, listing.tokenId), 100); + assertEq(erc1155.balanceOf(recipient, listing.tokenId), 0); + + MockTransferListingTokens(marketplace).transferListingTokens(seller, recipient, 100, listing); + + assertEq(erc1155.balanceOf(seller, listing.tokenId), 0); + assertEq(erc1155.balanceOf(recipient, listing.tokenId), 100); + } + + function test_transferListingTokens_erc721() public { + IDirectListings.Listing memory listing = DirectListingsLogic(marketplace).getListing(listingId_erc721); + + assertEq(erc721.ownerOf(listing.tokenId), seller); + + MockTransferListingTokens(marketplace).transferListingTokens(seller, recipient, 1, listing); + + assertEq(erc721.ownerOf(listing.tokenId), recipient); + } +} diff --git a/src/test/marketplace/direct-listings/_transferListingTokens/_transferListingTokens.tree b/src/test/marketplace/direct-listings/_transferListingTokens/_transferListingTokens.tree new file mode 100644 index 000000000..02204ec44 --- /dev/null +++ b/src/test/marketplace/direct-listings/_transferListingTokens/_transferListingTokens.tree @@ -0,0 +1,10 @@ +function _transferListingTokens( + address _from, + address _to, + uint256 _quantity, + Listing memory _listing +) +├── when the token is ERC1155 +│ └── it should transfer ERC1155 tokens from the specified owner to recipient +└── when the token is ERC721 + └── it should transfer ERC721 tokens from the specified owner to recipient \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/_validateERC20BalAndAllowance/_validateERC20BalAndAllowance.t.sol b/src/test/marketplace/direct-listings/_validateERC20BalAndAllowance/_validateERC20BalAndAllowance.t.sol new file mode 100644 index 000000000..f57643ca0 --- /dev/null +++ b/src/test/marketplace/direct-listings/_validateERC20BalAndAllowance/_validateERC20BalAndAllowance.t.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract MockValidateERC20BalAndAllowance is DirectListingsLogic { + constructor(address _nativeTokenWrapper) DirectListingsLogic(_nativeTokenWrapper) {} + + function validateERC20BalAndAllowance( + address _tokenOwner, + address _currency, + uint256 _amount + ) external returns (bool) { + _validateERC20BalAndAllowance(_tokenOwner, _currency, _amount); + return true; + } +} + +contract ValidateERC20BalAndAllowanceTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + 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; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + // Mint 100 ERC1155 NFT to seller + erc1155.mint(seller, listingParams.tokenId, 100); + // Mint some ERC20 tokens to seller + erc20.mint(seller, 100 ether); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `MockValidateERC20BalAndAllowance` + address directListings = address(new MockValidateERC20BalAndAllowance(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "MockValidateERC20BalAndAllowance", + metadataURI: "ipfs://MockValidateERC20BalAndAllowance", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](1); + extension_directListings.functions[0] = ExtensionFunction( + MockValidateERC20BalAndAllowance.validateERC20BalAndAllowance.selector, + "validateERC20BalAndAllowance(address,address,uint256)" + ); + extensions[0] = extension_directListings; + } + + function test_validateERC20BalAndAllowance_whenInsufficientTokensOwned() public { + vm.startPrank(seller); + + erc20.approve(marketplace, 100 ether); + erc20.burn(1 ether); + + vm.stopPrank(); + + vm.expectRevert("!BAL20"); + MockValidateERC20BalAndAllowance(marketplace).validateERC20BalAndAllowance(seller, address(erc20), 100 ether); + } + + function test_validateERC20BalAndAllowance_whenTokensNotApprovedToTransfer() public { + vm.startPrank(seller); + erc20.approve(marketplace, 0); + vm.stopPrank(); + + vm.expectRevert("!BAL20"); + MockValidateERC20BalAndAllowance(marketplace).validateERC20BalAndAllowance(seller, address(erc20), 100 ether); + } + + function test_validateERC20BalAndAllowance_whenTokensOwnedAndApproved() public { + vm.prank(seller); + erc20.approve(marketplace, 100 ether); + + bool result = MockValidateERC20BalAndAllowance(marketplace).validateERC20BalAndAllowance( + seller, + address(erc20), + 100 ether + ); + assertEq(result, true); + } +} diff --git a/src/test/marketplace/direct-listings/_validateERC20BalAndAllowance/_validateERC20BalAndAllowance.tree b/src/test/marketplace/direct-listings/_validateERC20BalAndAllowance/_validateERC20BalAndAllowance.tree new file mode 100644 index 000000000..04b6010d4 --- /dev/null +++ b/src/test/marketplace/direct-listings/_validateERC20BalAndAllowance/_validateERC20BalAndAllowance.tree @@ -0,0 +1,11 @@ +function _validateERC20BalAndAllowance( + address _tokenOwner, + address _currency, + uint256 _amount +) +├── when the balance of token owner is less than expected _amount +│ └── it should revert ✅ +├── when marketplace is not approved to spend token owner's token +│ └── it should revert ✅ +└── when the balance of token owner is greater than or equal to expected _amount and marketplace is approved to spend token owner's token + └── it should return ✅ \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/_validateNewListing/_validateNewListing.t.sol b/src/test/marketplace/direct-listings/_validateNewListing/_validateNewListing.t.sol new file mode 100644 index 000000000..51b681e34 --- /dev/null +++ b/src/test/marketplace/direct-listings/_validateNewListing/_validateNewListing.t.sol @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract MockValidateListing is DirectListingsLogic { + constructor(address _nativeTokenWrapper) DirectListingsLogic(_nativeTokenWrapper) {} + + function validateNewListing(ListingParameters memory _params, TokenType _tokenType) external returns (bool) { + _validateNewListing(_params, _tokenType); + return true; + } +} + +contract ValidateNewListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + 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; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + // Mint 100 ERC1155 NFT to seller + erc1155.mint(seller, listingParams.tokenId, 100); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `MockValidateListing` + address directListings = address(new MockValidateListing(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "MockValidateListing", + metadataURI: "ipfs://MockValidateListing", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](1); + extension_directListings.functions[0] = ExtensionFunction( + MockValidateListing.validateNewListing.selector, + "validateNewListing((address,uint256,uint256,address,uint256,uint128,uint128,bool),uint8)" + ); + extensions[0] = extension_directListings; + } + + function test_validateNewListing_whenQuantityIsZero() public { + listingParams.quantity = 0; + + vm.expectRevert("Marketplace: listing zero quantity."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC721); + } + + modifier whenQuantityIsOne() { + listingParams.quantity = 1; + _; + } + + modifier whenQuantityIsGtOne() { + listingParams.quantity = 2; + _; + } + + modifier whenTokenIsERC721() { + listingParams.assetContract = address(erc721); + _; + } + + modifier whenTokenIsERC1155() { + listingParams.assetContract = address(erc1155); + _; + } + + function test_validateNewListing_whenTokenIsERC721() public whenQuantityIsGtOne { + vm.expectRevert("Marketplace: listing invalid quantity."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC721); + } + + function test_validateNewListing_whenTokenOwnerDoesntOwnSufficientTokens_1() + public + whenQuantityIsGtOne + whenTokenIsERC1155 + { + vm.startPrank(seller); + erc1155.setApprovalForAll(marketplace, true); + erc1155.burn(seller, listingParams.tokenId, 100); + vm.stopPrank(); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC1155); + } + + modifier whenTokenOwnerOwnsSufficientTokens() { + _; + } + + function test_validateNewListing_whenTokensNotApprovedForTransfer_1() + public + whenQuantityIsGtOne + whenTokenIsERC1155 + whenTokenOwnerOwnsSufficientTokens + { + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, false); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC1155); + } + + modifier whenTokensApprovedForTransfer(IDirectListings.TokenType tokenType) { + vm.prank(seller); + if (tokenType == IDirectListings.TokenType.ERC721) { + erc721.setApprovalForAll(marketplace, true); + } else { + erc1155.setApprovalForAll(marketplace, true); + } + _; + } + + function test_validateNewListing_whenTokensOwnedAndApproved_1() + public + whenQuantityIsGtOne + whenTokenIsERC1155 + whenTokenOwnerOwnsSufficientTokens + whenTokensApprovedForTransfer(IDirectListings.TokenType.ERC1155) + { + vm.prank(seller); + assertEq( + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC1155), + true + ); + } + + function test_validateNewListing_whenTokenOwnerDoesntOwnSufficientTokens_2a() + public + whenQuantityIsOne + whenTokenIsERC1155 + { + vm.startPrank(seller); + erc1155.setApprovalForAll(marketplace, true); + erc1155.burn(seller, listingParams.tokenId, 100); + vm.stopPrank(); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC1155); + } + + function test_validateNewListing_whenTokenOwnerDoesntOwnSufficientTokens_2b() + public + whenQuantityIsOne + whenTokenIsERC721 + { + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + erc721.burn(listingParams.tokenId); + vm.stopPrank(); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC721); + } + + function test_validateNewListing_whenTokensNotApprovedForTransfer_2a() + public + whenQuantityIsOne + whenTokenIsERC721 + whenTokenOwnerOwnsSufficientTokens + { + vm.prank(seller); + erc721.setApprovalForAll(marketplace, false); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC721); + } + + function test_validateNewListing_whenTokensNotApprovedForTransfer_2b() + public + whenQuantityIsOne + whenTokenIsERC1155 + whenTokenOwnerOwnsSufficientTokens + { + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, false); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC1155); + } + + function test_validateNewListing_whenTokensOwnedAndApproved_2a() + public + whenQuantityIsOne + whenTokenIsERC1155 + whenTokenOwnerOwnsSufficientTokens + whenTokensApprovedForTransfer(IDirectListings.TokenType.ERC1155) + { + vm.prank(seller); + assertEq( + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC1155), + true + ); + } + + function test_validateNewListing_whenTokensOwnedAndApproved_2b() + public + whenQuantityIsOne + whenTokenIsERC721 + whenTokenOwnerOwnsSufficientTokens + whenTokensApprovedForTransfer(IDirectListings.TokenType.ERC721) + { + vm.prank(seller); + assertEq( + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC721), + true + ); + } +} diff --git a/src/test/marketplace/direct-listings/_validateNewListing/_validateNewListing.tree b/src/test/marketplace/direct-listings/_validateNewListing/_validateNewListing.tree new file mode 100644 index 000000000..a2520cf27 --- /dev/null +++ b/src/test/marketplace/direct-listings/_validateNewListing/_validateNewListing.tree @@ -0,0 +1,23 @@ +function _validateNewListing(ListingParameters memory _params, TokenType _tokenType) +├── when quantity is zero +│ └── it should revert ✅ +└── when quantity is non zero + ├── when quantity is greater than one + │ ├── when token type is ERC721 + │ │ └── it should revert ✅ + │ └── when the token type is ERC1155 + │ ├── when the token owner owns less than quantity to list + │ │ └── it should revert ✅ + │ └── when the token owner owns sufficient quantity + │ ├── when the marketplace is not approved to transfer tokens + │ │ └── it should revert ✅ + │ └── when the marketplace is approved to transfer tokens + │ └── it should return ✅ + └── when the quantity is one + ├── when the token owner owns less than quantity to list + │ └── it should revert ✅ + └── when the token owner owns sufficient quantity + ├── when the marketplace is not approved to transfer tokens + │ └── it should revert ✅ + └── when the marketplace is approved to transfer tokens + └── it should return ✅ \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/_validateOwnershipAndApproval/_validateOwnershipAndApproval.t.sol b/src/test/marketplace/direct-listings/_validateOwnershipAndApproval/_validateOwnershipAndApproval.t.sol new file mode 100644 index 000000000..5436d89f7 --- /dev/null +++ b/src/test/marketplace/direct-listings/_validateOwnershipAndApproval/_validateOwnershipAndApproval.t.sol @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract MockValidateOwnershipAndApproval is DirectListingsLogic { + constructor(address _nativeTokenWrapper) DirectListingsLogic(_nativeTokenWrapper) {} + + function validateOwnershipAndApproval( + address _tokenOwner, + address _assetContract, + uint256 _tokenId, + uint256 _quantity, + TokenType _tokenType + ) external view returns (bool) { + return _validateOwnershipAndApproval(_tokenOwner, _assetContract, _tokenId, _quantity, _tokenType); + } +} + +contract ValidateOwnershipAndApprovalTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + 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; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + // Mint 100 ERC1155 NFT to seller + erc1155.mint(seller, listingParams.tokenId, 100); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `MockValidateListing` + address directListings = address(new MockValidateOwnershipAndApproval(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "MockValidateOwnershipAndApproval", + metadataURI: "ipfs://MockValidateOwnershipAndApproval", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](1); + extension_directListings.functions[0] = ExtensionFunction( + MockValidateOwnershipAndApproval.validateOwnershipAndApproval.selector, + "validateOwnershipAndApproval(address,address,uint256,uint256,uint8)" + ); + extensions[0] = extension_directListings; + } + + modifier whenTokenIsERC1155() { + listingParams.assetContract = address(erc1155); + listingParams.quantity = 100; + _; + } + + modifier whenTokenIsERC721() { + listingParams.assetContract = address(erc721); + listingParams.quantity = 1; + _; + } + + function test_validateOwnershipAndApproval_whenInsufficientTokensOwned_erc1155() public whenTokenIsERC1155 { + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, true); + + vm.prank(seller); + erc1155.burn(seller, listingParams.tokenId, 100); + + bool result = MockValidateOwnershipAndApproval(marketplace).validateOwnershipAndApproval( + seller, + listingParams.assetContract, + listingParams.tokenId, + listingParams.quantity, + IDirectListings.TokenType.ERC1155 + ); + assertEq(result, false); + } + + function test_validateOwnershipAndApproval_whenInsufficientTokensOwned_erc721() public whenTokenIsERC721 { + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + vm.prank(seller); + erc721.burn(listingParams.tokenId); + + bool result = MockValidateOwnershipAndApproval(marketplace).validateOwnershipAndApproval( + seller, + listingParams.assetContract, + listingParams.tokenId, + listingParams.quantity, + IDirectListings.TokenType.ERC721 + ); + assertEq(result, false); + } + + modifier whenSufficientTokensOwned() { + _; + } + + function test_validateOwnershipAndApproval_whenTokensNotApprovedToTransfer_erc1155() + public + whenTokenIsERC1155 + whenSufficientTokensOwned + { + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), listingParams.quantity); + + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, false); + + bool result = MockValidateOwnershipAndApproval(marketplace).validateOwnershipAndApproval( + seller, + listingParams.assetContract, + listingParams.tokenId, + listingParams.quantity, + IDirectListings.TokenType.ERC1155 + ); + assertEq(result, false); + } + + function test_validateOwnershipAndApproval_whenTokensNotApprovedToTransfer_erc721() + public + whenTokenIsERC721 + whenSufficientTokensOwned + { + assertEq(erc721.ownerOf(listingParams.tokenId), seller); + + vm.prank(seller); + erc721.setApprovalForAll(marketplace, false); + + bool result = MockValidateOwnershipAndApproval(marketplace).validateOwnershipAndApproval( + seller, + listingParams.assetContract, + listingParams.tokenId, + listingParams.quantity, + IDirectListings.TokenType.ERC721 + ); + assertEq(result, false); + } + + modifier whenTokensApprovedForTransfer(IDirectListings.TokenType tokenType) { + vm.prank(seller); + if (tokenType == IDirectListings.TokenType.ERC1155) { + erc1155.setApprovalForAll(marketplace, true); + } else { + erc721.setApprovalForAll(marketplace, true); + } + _; + } + + function test_validateOwnershipAndApproval_whenTokensOwnedAndApproved_erc1155() + public + whenTokenIsERC1155 + whenSufficientTokensOwned + whenTokensApprovedForTransfer(IDirectListings.TokenType.ERC1155) + { + bool result = MockValidateOwnershipAndApproval(marketplace).validateOwnershipAndApproval( + seller, + listingParams.assetContract, + listingParams.tokenId, + listingParams.quantity, + IDirectListings.TokenType.ERC1155 + ); + assertEq(result, true); + } + + function test_validateOwnershipAndApproval_whenTokensOwnedAndApproved_erc721() + public + whenTokenIsERC721 + whenSufficientTokensOwned + whenTokensApprovedForTransfer(IDirectListings.TokenType.ERC721) + { + bool result = MockValidateOwnershipAndApproval(marketplace).validateOwnershipAndApproval( + seller, + listingParams.assetContract, + listingParams.tokenId, + listingParams.quantity, + IDirectListings.TokenType.ERC721 + ); + assertEq(result, true); + } +} diff --git a/src/test/marketplace/direct-listings/_validateOwnershipAndApproval/_validateOwnershipAndApproval.tree b/src/test/marketplace/direct-listings/_validateOwnershipAndApproval/_validateOwnershipAndApproval.tree new file mode 100644 index 000000000..2ee82b493 --- /dev/null +++ b/src/test/marketplace/direct-listings/_validateOwnershipAndApproval/_validateOwnershipAndApproval.tree @@ -0,0 +1,21 @@ +function _validateOwnershipAndApproval( + address _tokenOwner, + address _assetContract, + uint256 _tokenId, + uint256 _quantity, + TokenType _tokenType +) +├── when token type is ERC1155 +│ ├── when token balance of owner is less than expected quantity +│ │ └── it should return false ✅ +│ ├── when marketplace is not approved to transfer tokens +│ │ └── it should return false ✅ +│ └── when token balance of owner is gte expected quantity and marketplace is approved to transfer tokens +│ └── it should return true ✅ +└── when token type is ERC721 + ├── when token owner is not the expected owner of the token + │ └── it should return false ✅ + ├── when marketplace is not approved to transfer tokens + │ └── it should return false ✅ + └── when token owner is the expected owner of the token and marketplace is approved to transfer tokens + └── it should return true ✅ diff --git a/src/test/marketplace/direct-listings/approveBuyerForListing/approveBuyerForListing.t.sol b/src/test/marketplace/direct-listings/approveBuyerForListing/approveBuyerForListing.t.sol new file mode 100644 index 000000000..bdd8dbae8 --- /dev/null +++ b/src/test/marketplace/direct-listings/approveBuyerForListing/approveBuyerForListing.t.sol @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract ApproveBuyerForListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + uint256 internal listingId = 0; + + // Events to test + + /// @notice Emitted when a buyer is approved to buy from a reserved listing. + event BuyerApprovedForListing(uint256 indexed listingId, address indexed buyer, bool approved); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100 minutes; + uint128 endTimestamp = 200 minutes; + bool reserved = false; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + function test_approveBuyerForListing_listingDoesntExist() public { + vm.prank(seller); + vm.expectRevert("Marketplace: invalid listing."); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + } + + modifier whenListingExists() { + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + vm.stopPrank(); + _; + } + + function test_approveBuyerForListing_whenCallerNotListingCreator() public whenListingExists { + vm.prank(address(0x4353)); + vm.expectRevert("Marketplace: not listing creator."); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + } + + modifier whenCallerIsListingCreator() { + _; + } + + function test_approveBuyerForListing_whenListingNotReserved() public whenListingExists whenCallerIsListingCreator { + vm.prank(seller); + vm.expectRevert("Marketplace: listing not reserved."); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + } + + modifier whenListingIsReserved() { + listingParams.reserved = true; + + vm.prank(seller); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + _; + } + + function test_approveBuyerForListing_whenListingIsReserved() + public + whenListingExists + whenCallerIsListingCreator + whenListingIsReserved + { + assertEq(DirectListingsLogic(marketplace).isBuyerApprovedForListing(listingId, buyer), false); + + vm.prank(seller); + vm.expectEmit(true, true, true, false); + emit BuyerApprovedForListing(listingId, buyer, true); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + assertEq(DirectListingsLogic(marketplace).isBuyerApprovedForListing(listingId, buyer), true); + } +} diff --git a/src/test/marketplace/direct-listings/approveBuyerForListing/approveBuyerForListing.tree b/src/test/marketplace/direct-listings/approveBuyerForListing/approveBuyerForListing.tree new file mode 100644 index 000000000..5b7aeff2a --- /dev/null +++ b/src/test/marketplace/direct-listings/approveBuyerForListing/approveBuyerForListing.tree @@ -0,0 +1,15 @@ +function approveBuyerForListing( + uint256 _listingId, + address _buyer, + bool _toApprove +) +├── when the lisitng does not exist +│ └── it should revert ✅ +└── when the listing exists + ├── when the caller is not listing creator + │ └── it should revert ✅ + └── when the caller is listing creator + ├── when the listing is not reserved + │ └── it should revert ✅ + └── when the listing is reserved + └── it should set the intended approval status for buyer ✅ \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/approveCurrencyForListing/approveCurrencyForListing.t.sol b/src/test/marketplace/direct-listings/approveCurrencyForListing/approveCurrencyForListing.t.sol new file mode 100644 index 000000000..e4e70007d --- /dev/null +++ b/src/test/marketplace/direct-listings/approveCurrencyForListing/approveCurrencyForListing.t.sol @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract ApproveCurrencyForListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + uint256 internal listingId = 0; + + // Events to test + + /// @notice Emitted when a currency is approved as a form of payment for the listing. + event CurrencyApprovedForListing(uint256 indexed listingId, address indexed currency, uint256 pricePerToken); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100 minutes; + uint128 endTimestamp = 200 minutes; + bool reserved = true; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + function test_approveCurrencyForListing_listingDoesntExist() public { + vm.prank(seller); + vm.expectRevert("Marketplace: invalid listing."); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 1 ether); + } + + modifier whenListingExists() { + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + erc721.setApprovalForAll(marketplace, false); + vm.stopPrank(); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(erc721)); + _; + } + + function test_approveCurrencyForListing_whenCallerNotListingCreator() public whenListingExists { + vm.prank(address(0x4353)); + vm.expectRevert("Marketplace: not listing creator."); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 1 ether); + } + + modifier whenCallerIsListingCreator() { + _; + } + + function test_approveCurrencyForListing_whenApprovingDifferentPriceForListedCurrency() + public + whenListingExists + whenCallerIsListingCreator + { + vm.prank(seller); + vm.expectRevert("Marketplace: approving listing currency with different price."); + DirectListingsLogic(marketplace).approveCurrencyForListing( + listingId, + listingParams.currency, + listingParams.pricePerToken + 1 + ); + } + + function test_approveCurrencyForListing_whenPriceToApproveIsAlreadyApproved() + public + whenListingExists + whenCallerIsListingCreator + { + vm.prank(seller); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 1 ether); + + vm.prank(seller); + vm.expectRevert("Marketplace: price unchanged."); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 1 ether); + } + + function test_approveCurrencyForListing_whenApprovedPriceForCurrencyIsDifferentThanIncumbent() + public + whenListingExists + whenCallerIsListingCreator + { + vm.expectRevert("Currency not approved for listing"); + DirectListingsLogic(marketplace).currencyPriceForListing(listingId, address(weth)); + + vm.prank(seller); + vm.expectEmit(true, true, true, true); + emit CurrencyApprovedForListing(listingId, address(weth), 1 ether); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 1 ether); + + assertEq(DirectListingsLogic(marketplace).currencyPriceForListing(listingId, address(weth)), 1 ether); + } +} diff --git a/src/test/marketplace/direct-listings/approveCurrencyForListing/approveCurrencyForListing.tree b/src/test/marketplace/direct-listings/approveCurrencyForListing/approveCurrencyForListing.tree new file mode 100644 index 000000000..da7687454 --- /dev/null +++ b/src/test/marketplace/direct-listings/approveCurrencyForListing/approveCurrencyForListing.tree @@ -0,0 +1,19 @@ +function approveCurrencyForListing( + uint256 _listingId, + address _currency, + uint256 _pricePerTokenInCurrency +) +├── when listing does not exist +│ └── it should revert ✅ +└── when the listing exists + ├── when the caller is not listing creator + │ └── it should revert ✅ + └── when the caller is listing creator + ├── when approving different price for listed currency + │ └── it should revert ✅ + └── when not approving different price for listed currency + ├── when prive to approve for curreny is already approved + │ └── it should revert ✅ + └── when approving a new price for currency ✅ + ├── it should update the approved price for currency + └── it should emit CurrencyApprovedForListing event with the listing ID, currency and approved price \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/buyFromListing/buyFromListing.t.sol b/src/test/marketplace/direct-listings/buyFromListing/buyFromListing.t.sol new file mode 100644 index 000000000..8c11e6540 --- /dev/null +++ b/src/test/marketplace/direct-listings/buyFromListing/buyFromListing.t.sol @@ -0,0 +1,636 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { PlatformFee } from "contracts/extension/PlatformFee.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; +import { MockRoyaltyEngineV1 } from "../../../mocks/MockRoyaltyEngineV1.sol"; +import { ERC1155Holder } from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; + +contract ReentrantRecipient is ERC1155Holder { + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes memory data + ) public virtual override returns (bytes4) { + DirectListingsLogic(msg.sender).buyFromListing(0, address(this), 1, address(0), 0); + return super.onERC1155Received(operator, from, id, value, data); + } +} + +contract BuyFromListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + + uint256 internal listingId = type(uint256).max; + uint256 internal listingId_native_noSpecialPrice = 0; + uint256 internal listingId_native_specialPrice = 1; + uint256 internal listingId_erc20_noSpecialPrice = 2; + uint256 internal listingId_erc20_specialPrice = 3; + + // Events to test + + /// @notice Emitted when NFTs are bought from a listing. + event NewSale( + address indexed listingCreator, + uint256 indexed listingId, + address indexed assetContract, + uint256 tokenId, + address buyer, + uint256 quantityBought, + uint256 totalPricePaid + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), platformFeeRecipient, uint16(platformFeeBps)) + ) + ) + ); + + // Setup listing params + address assetContract = address(erc1155); + uint256 tokenId = 0; + uint256 quantity = 10; + address currency = NATIVE_TOKEN; + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100 minutes; + uint128 endTimestamp = 200 minutes; + bool reserved = false; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint currency to buyer + vm.deal(buyer, 100 ether); + erc20.mint(buyer, 100 ether); + + // Mint an ERC721 NFTs to seller + erc1155.mint(seller, 0, 100); + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, true); + + // Create 4 listings + vm.startPrank(seller); + + // 1. Native token, no special price + listingParams.currency = NATIVE_TOKEN; + listingId_native_noSpecialPrice = DirectListingsLogic(marketplace).createListing(listingParams); + + // 2. Native token, special price + listingParams.currency = address(erc20); + listingId_native_specialPrice = DirectListingsLogic(marketplace).createListing(listingParams); + DirectListingsLogic(marketplace).approveCurrencyForListing( + listingId_native_specialPrice, + NATIVE_TOKEN, + 2 ether + ); + + // 3. ERC20 token, no special price + listingParams.currency = address(erc20); + listingId_erc20_noSpecialPrice = DirectListingsLogic(marketplace).createListing(listingParams); + + // 4. ERC20 token, special price + listingParams.currency = NATIVE_TOKEN; + listingId_erc20_specialPrice = DirectListingsLogic(marketplace).createListing(listingParams); + DirectListingsLogic(marketplace).approveCurrencyForListing( + listingId_erc20_specialPrice, + address(erc20), + 2 ether + ); + + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + modifier whenListingCurrencyIsNativeToken() { + listingId = listingId_native_noSpecialPrice; + listingParams.currency = NATIVE_TOKEN; + _; + } + + modifier whenListingHasSpecialPriceNativeToken() { + listingId = listingId_native_specialPrice; + _; + } + + modifier whenListingCurrencyIsERC20Token() { + listingId = listingId_erc20_noSpecialPrice; + _; + } + + modifier whenListingHasSpecialPriceERC20Token() { + listingId = listingId_erc20_specialPrice; + _; + } + + //////////// ASSUME NATIVE_TOKEN && SPECIAL_PRICE //////////// + + function test_buyFromListing_whenCallIsReentrant() public whenListingHasSpecialPriceNativeToken { + vm.warp(listingParams.startTimestamp); + address reentrantRecipient = address(new ReentrantRecipient()); + + vm.prank(buyer); + vm.expectRevert(); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }( + listingId, + reentrantRecipient, + 1, + NATIVE_TOKEN, + 2 ether + ); + } + + modifier whenCallIsNotReentrant() { + _; + } + + function test_buyFromListing_whenListingDoesNotExist() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + { + vm.warp(listingParams.startTimestamp); + vm.prank(buyer); + vm.expectRevert("Marketplace: invalid listing."); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(100, buyer, 1, NATIVE_TOKEN, 2 ether); + } + + modifier whenListingExists() { + _; + } + + function test_buyFromListing_whenBuyerIsNotApprovedForListing() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + { + listingParams.reserved = true; + listingParams.currency = address(erc20); + + vm.prank(seller); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("buyer not approved"); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(listingId, buyer, 1, NATIVE_TOKEN, 2 ether); + } + + modifier whenBuyerIsApprovedForListing(address _currency) { + listingParams.reserved = true; + listingParams.currency = _currency; + + vm.prank(seller); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + _; + } + + function test_buyFromListing_whenQuantityToBuyIsInvalid() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + { + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("Buying invalid quantity"); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(listingId, buyer, 0, NATIVE_TOKEN, 2 ether); + } + + modifier whenQuantityToBuyIsValid() { + _; + } + + function test_buyFromListing_whenListingIsInactive() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + { + vm.prank(buyer); + vm.expectRevert("not within sale window."); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(listingId, buyer, 1, NATIVE_TOKEN, 2 ether); + } + + modifier whenListingIsActive() { + _; + } + + function test_buyFromListing_whenListedAssetNotOwnedOrApprovedToTransfer() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + { + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, false); + + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("Marketplace: not owner or approved tokens."); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(listingId, buyer, 1, NATIVE_TOKEN, 2 ether); + } + + modifier whenListedAssetOwnedAndApproved() { + _; + } + + function test_buyFromListing_whenExpectedPriceNotActualPrice() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + { + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("Unexpected total price"); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(listingId, buyer, 1, NATIVE_TOKEN, 1 ether); + } + + modifier whenExpectedPriceIsActualPrice() { + _; + } + + function test_buyFromListing_whenMsgValueNotEqTotalPrice() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + { + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("Marketplace: msg.value must exactly be the total price."); + DirectListingsLogic(marketplace).buyFromListing{ value: 1 ether }(listingId, buyer, 1, NATIVE_TOKEN, 2 ether); + } + + modifier whenMsgValueEqTotalPrice() { + _; + } + + function test_buyFromListing_whenAllRemainingQtyIsBought_nativeToken() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + whenMsgValueEqTotalPrice + { + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 100); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), 0); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.CREATED) + ); + + vm.warp(listingParams.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether * listingParams.quantity }( + listingId, + buyer, + listingParams.quantity, + NATIVE_TOKEN, + 2 ether * listingParams.quantity + ); + + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 100 - listingParams.quantity); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), listingParams.quantity); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.COMPLETED) + ); + } + + function test_buyFromListing_whenSomeRemainingQtyIsBought_nativeToken() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + whenMsgValueEqTotalPrice + { + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 100); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), 0); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.CREATED) + ); + + vm.warp(listingParams.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(listingId, buyer, 1, NATIVE_TOKEN, 2 ether); + + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 99); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), 1); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.CREATED) + ); + } + + //////////// ASSUME NATIVE_TOKEN && NO_SPECIAL_PRICE //////////// + + function test_buyFromListing_whenCurrencyToUseNotListedCurrency() + public + whenListingCurrencyIsNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(NATIVE_TOKEN) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + { + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("Paying in invalid currency."); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether * listingParams.quantity }( + listingId, + buyer, + listingParams.quantity, + address(erc20), + 2 ether * listingParams.quantity + ); + } + + //////////// ASSUME ERC20 && NO_SPECIAL_PRICE //////////// + + function test_buyFromListing_whenInsufficientTokenBalanceOrAllowance() + public + whenListingCurrencyIsERC20Token + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + { + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("!BAL20"); + DirectListingsLogic(marketplace).buyFromListing( + listingId, + buyer, + listingParams.quantity, + address(erc20), + 1 ether * listingParams.quantity + ); + } + + modifier whenSufficientTokenBalanceOrAllowance() { + vm.prank(buyer); + erc20.approve(marketplace, 100 ether); + _; + } + + function test_buyFromListing_whenMsgValueNotZero() + public + whenListingCurrencyIsERC20Token + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + whenSufficientTokenBalanceOrAllowance + { + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("Marketplace: invalid native tokens sent."); + DirectListingsLogic(marketplace).buyFromListing{ value: 1 ether }( + listingId, + buyer, + listingParams.quantity, + address(erc20), + 1 ether * listingParams.quantity + ); + } + + modifier whenMsgValueIsZero() { + _; + } + + function test_buyFromListing_whenAllRemainingQtyIsBought_erc20() + public + whenListingCurrencyIsERC20Token + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + whenSufficientTokenBalanceOrAllowance + whenMsgValueIsZero + { + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 100); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), 0); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.CREATED) + ); + + vm.warp(listingParams.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing( + listingId, + buyer, + listingParams.quantity, + address(erc20), + 1 ether * listingParams.quantity + ); + + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 100 - listingParams.quantity); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), listingParams.quantity); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.COMPLETED) + ); + } + + function test_buyFromListing_whenSomeRemainingQtyIsBought_erc20() + public + whenListingCurrencyIsERC20Token + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + whenSufficientTokenBalanceOrAllowance + whenMsgValueIsZero + { + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 100); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), 0); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.CREATED) + ); + + vm.warp(listingParams.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing(listingId, buyer, 1, address(erc20), 1 ether); + + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 99); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), 1); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.CREATED) + ); + } +} diff --git a/src/test/marketplace/direct-listings/buyFromListing/buyFromListing.tree b/src/test/marketplace/direct-listings/buyFromListing/buyFromListing.tree new file mode 100644 index 000000000..0b7ae81fa --- /dev/null +++ b/src/test/marketplace/direct-listings/buyFromListing/buyFromListing.tree @@ -0,0 +1,172 @@ +function buyFromListing( + uint256 _listingId, + address _buyFor, + uint256 _quantity, + address _currency, + uint256 _expectedTotalPrice +) + +// ASSUME NATIVE_TOKEN && SPECIAL_PRICE +. +├── when the call is reentrant +│ └── it should revert +└── when the call is not reentrant + ├── when no listing with the given listing ID exists + │ └── it should revert + └── when listing with the given listing ID exists + ├── when the listing is reserved and caller is not approved for listing + │ └── it should revert + └── when the listing is not reserved OR caller is approved for listing + ├── when quantity to buy is invalid i.e. zero OR exceeds the listing quantity + │ └── it should revert + └── when quantity to buy is valid i.e. non-zero and does not exceed the listing quantity + ├── when the listing is inactive + │ └── it should revert + └── when the listing is active + ├── when the asset is not owned by listing creator or marketplace is not approved for transfer + │ └── it should revert + └── when the asset is owned by listing creator and marketplace is approved for transfer + ├── when the calculated total price is not equal to the expected total price + │ └── it should revert + └── when the calculated total price is equal to the expected total price + ├── when msg.value is not equal to the calculated total price + │ └── it should revert + └── when msg.value is equal to the calculated total price + ├── when the quantity bought is the total remaining listing quantity + │ ├── it should set the status of the lisitng as complete + │ ├── it should subtract the quantity to buy from the listing quantity + │ ├── it should payout platform fees and royalty fees to respective recipients + │ ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + │ └── it should emit NewSale event with the correct total price to paid + └── when the quantity bought is not the total remaining listing quantity + ├── it should subtract the quantity to buy from the listing quantity + ├── it should payout platform fees and royalty fees to respective recipients + ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + └── it should emit NewSale event with the correct total price to paid + +// ASSUME NATIVE_TOKEN && NO_SPECIAL_PRICE +. +├── when the call is reentrant +│ └── it should revert +└── when the call is not reentrant + ├── when no listing with the given listing ID exists + │ └── it should revert + └── when listing with the given listing ID exists + ├── when the listing is reserved and caller is not approved for listing + │ └── it should revert + └── when the listing is not reserved OR caller is approved for listing + ├── when quantity to buy is invalid i.e. zero OR exceeds the listing quantity + │ └── it should revert + └── when quantity to buy is valid i.e. non-zero and does not exceed the listing quantity + ├── when the listing is inactive + │ └── it should revert + └── when the listing is active + ├── when the asset is not owned by listing creator or marketplace is not approved for transfer + │ └── it should revert + └── when the asset is owned by listing creator and marketplace is approved for transfer + ├── when the currency to pay in is not the listing's accepted currency + │ └── it should revert + └── when the currency to pay in is the listing's accepted currency + ├── when the calculated total price is not equal to the expected total price + │ └── it should revert + └── when the calculated total price is equal to the expected total price + ├── when the msg.value is not equal to the calculated total price + │ └── it should revert + └── when the msg.value is equal to the calculated total price + ├── when the quantity bought is the total remaining listing quantity + │ ├── it should set the status of the lisitng as complete + │ ├── it should subtract the quantity to buy from the listing quantity + │ ├── it should payout platform fees and royalty fees to respective recipients + │ ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + │ └── it should emit NewSale event with the correct total price to paid + └── when the quantity bought is not the total remaining listing quantity + ├── it should subtract the quantity to buy from the listing quantity + ├── it should payout platform fees and royalty fees to respective recipients + ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + └── it should emit NewSale event with the correct total price to paid + +// ASSUME ERC20 && NO_SPECIAL_PRICE +. +├── when the call is reentrant +│ └── it should revert +└── when the call is not reentrant + ├── when no listing with the given listing ID exists + │ └── it should revert + └── when listing with the given listing ID exists + ├── when the listing is reserved and caller is not approved for listing + │ └── it should revert + └── when the listing is not reserved OR caller is approved for listing + ├── when quantity to buy is invalid i.e. zero OR exceeds the listing quantity + │ └── it should revert + └── when quantity to buy is valid i.e. non-zero and does not exceed the listing quantity + ├── when the listing is inactive + │ └── it should revert + └── when the listing is active + ├── when the asset is not owned by listing creator or marketplace is not approved for transfer + │ └── it should revert + └── when the asset is owned by listing creator and marketplace is approved for transfer + ├── when the currency to pay in is not the listing's accepted currency + │ └── it should revert + └── when the currency to pay in is the listing's accepted currency + ├── when the calculated total price is not equal to the expected total price + │ └── it should revert + └── when the calculated total price is equal to the expected total price + └── when ERC20 balance and allowance is invalid + ├── it should revert + └── when ERC20 balance and allowance is valid + ├── when msg.value is not zero + │ └── it should revert + └── when msg.value is zero + ├── when the quantity bought is the total remaining listing quantity + │ ├── it should set the status of the lisitng as complete + │ ├── it should subtract the quantity to buy from the listing quantity + │ ├── it should payout platform fees and royalty fees to respective recipients + │ ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + │ └── it should emit NewSale event with the correct total price to paid + └── when the quantity bought is not the total remaining listing quantity + ├── it should subtract the quantity to buy from the listing quantity + ├── it should payout platform fees and royalty fees to respective recipients + ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + └── it should emit NewSale event with the correct total price to paid + + +// ASSUME ERC20 && SPECIAL_PRICE +. +├── when the call is reentrant +│ └── it should revert +└── when the call is not reentrant + ├── when no listing with the given listing ID exists + │ └── it should revert + └── when listing with the given listing ID exists + ├── when the listing is reserved and caller is not approved for listing + │ └── it should revert + └── when the listing is not reserved OR caller is approved for listing + ├── when quantity to buy is invalid i.e. zero OR exceeds the listing quantity + │ └── it should revert + └── when quantity to buy is valid i.e. non-zero and does not exceed the listing quantity + ├── when the listing is inactive + │ └── it should revert + └── when the listing is active + ├── when the asset is not owned by listing creator or marketplace is not approved for transfer + │ └── it should revert + └── when the asset is owned by listing creator and marketplace is approved for transfer + ├── when the calculated total price is not equal to the expected total price + │ └── it should revert + └── when the calculated total price is equal to the expected total price + ├── when ERC20 balance and allowance is invalid + │ └── it should revert + └── when ERC20 balance and allowance is valid + ├── when msg.value is not zero + │ └── it should revert + └── when msg.value is zero + ├── when the quantity bought is the total remaining listing quantity + │ ├── it should set the status of the lisitng as complete + │ ├── it should subtract the quantity to buy from the listing quantity + │ ├── it should payout platform fees and royalty fees to respective recipients + │ ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + │ └── it should emit NewSale event with the correct total price to paid + └── when the quantity bought is not the total remaining listing quantity + ├── it should subtract the quantity to buy from the listing quantity + ├── it should payout platform fees and royalty fees to respective recipients + ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + └── it should emit NewSale event with the correct total price to paid diff --git a/src/test/marketplace/direct-listings/cancelListing/cancelListing.t.sol b/src/test/marketplace/direct-listings/cancelListing/cancelListing.t.sol new file mode 100644 index 000000000..6e77c4fe0 --- /dev/null +++ b/src/test/marketplace/direct-listings/cancelListing/cancelListing.t.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract CancelListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + uint256 internal listingId = 0; + + // Events to test + + /// @notice Emitted when a listing is updated. + event CancelledListing(address indexed listingCreator, uint256 indexed listingId); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100 minutes; + uint128 endTimestamp = 200 minutes; + bool reserved = true; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + function test_cancelListing_whenListingDoesntExist() public { + vm.prank(seller); + vm.expectRevert("Marketplace: invalid listing."); + DirectListingsLogic(marketplace).cancelListing(listingId); + } + + modifier whenListingExists() { + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + erc721.setApprovalForAll(marketplace, false); + vm.stopPrank(); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(erc721)); + _; + } + + function test_cancelListing_whenCallerNotListingCreator() public whenListingExists { + vm.prank(address(0x4567)); + vm.expectRevert("Marketplace: not listing creator."); + DirectListingsLogic(marketplace).cancelListing(listingId); + } + + modifier whenCallerIsListingCreator() { + _; + } + + function test_cancelListing_success() public whenListingExists whenCallerIsListingCreator { + vm.warp(listingParams.startTimestamp + 1); + + assertEq(uint8(DirectListingsLogic(marketplace).getListing(listingId).status), uint8(1)); // CREATED + + vm.prank(seller); + vm.expectEmit(true, true, true, true); + emit CancelledListing(seller, listingId); + DirectListingsLogic(marketplace).cancelListing(listingId); + + assertEq(uint8(DirectListingsLogic(marketplace).getListing(listingId).status), uint8(3)); // CANCELLED + } +} diff --git a/src/test/marketplace/direct-listings/cancelListing/cancelListing.tree b/src/test/marketplace/direct-listings/cancelListing/cancelListing.tree new file mode 100644 index 000000000..dd34eb23d --- /dev/null +++ b/src/test/marketplace/direct-listings/cancelListing/cancelListing.tree @@ -0,0 +1,9 @@ +function cancelListing(uint256 _listingId) +├── when no listing with the given listing ID exists +│ └── it should revert ✅ +└── when listing with the given listing ID exists + ├── when the caller is not listing creator + │ └── it should revert ✅ + └── when the caller is listing creator ✅ + ├── it should set status of listing as cancelled + └── it should emit CancelledListing event \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/createListing/createListing.t.sol b/src/test/marketplace/direct-listings/createListing/createListing.t.sol new file mode 100644 index 000000000..415a1b71d --- /dev/null +++ b/src/test/marketplace/direct-listings/createListing/createListing.t.sol @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract CreateListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + + // Events to test + + /// @notice Emitted when a new listing is created. + event NewListing( + address indexed listingCreator, + uint256 indexed listingId, + address indexed assetContract, + IDirectListings.Listing listing + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + 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; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + function test_createListing_whenCallerDoesNotHaveListerRole() public { + bytes32 role = keccak256("LISTER_ROLE"); + assertEq(Permissions(marketplace).hasRole(role, seller), false); + + vm.prank(seller); + vm.expectRevert("!LISTER_ROLE"); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + modifier whenCallerHasListerRole() { + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + _; + } + + function test_createListing_whenAssetDoesNotHaveAssetRole() public whenCallerHasListerRole { + bytes32 role = keccak256("ASSET_ROLE"); + assertEq(Permissions(marketplace).hasRole(role, listingParams.assetContract), false); + + vm.prank(seller); + vm.expectRevert("!ASSET_ROLE"); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + modifier whenAssetHasAssetRole() { + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), listingParams.assetContract); + _; + } + + function test_createListing_startTimeGteEndTime() public whenCallerHasListerRole whenAssetHasAssetRole { + listingParams.startTimestamp = 200; + listingParams.endTimestamp = 100; + + vm.prank(seller); + vm.expectRevert("Marketplace: endTimestamp not greater than startTimestamp."); + DirectListingsLogic(marketplace).createListing(listingParams); + + listingParams.endTimestamp = 200; + + vm.prank(seller); + vm.expectRevert("Marketplace: endTimestamp not greater than startTimestamp."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + modifier whenStartTimeLtEndTime() { + listingParams.startTimestamp = 100; + listingParams.endTimestamp = 200; + _; + } + + modifier whenStartTimeLtBlockTimestamp() { + // This warp has no effect on subsequent tests since they include a vm.warp in their own test body. + vm.warp(listingParams.startTimestamp + 1); + _; + } + + function test_createListing_whenStartTimeMoreThanHourBeforeBlockTimestamp() + public + whenCallerHasListerRole + whenAssetHasAssetRole + whenStartTimeLtEndTime + whenStartTimeLtBlockTimestamp + { + vm.warp(listingParams.startTimestamp + (60 minutes + 1)); + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid startTimestamp."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + modifier whenStartTimeWithinHourOfBlockTimestamp() { + vm.warp(listingParams.startTimestamp + 59 minutes); + _; + } + + function test_createListing_whenListingParamsAreInvalid_1() + public + whenCallerHasListerRole + whenAssetHasAssetRole + whenStartTimeLtEndTime + whenStartTimeLtBlockTimestamp + whenStartTimeWithinHourOfBlockTimestamp + { + // This is one of the ways in which params are considered invalid. + // We've written separate BTT tests for `_validateNewListing` + listingParams.quantity = 0; + + vm.prank(seller); + vm.expectRevert("Marketplace: listing zero quantity."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + modifier whenListingParamsAreValid() { + // Approve marketplace to transfer tokens -- else listing params are considered invalid. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + _; + } + + function test_createListing_whenListingParamsAreValid_1() + public + whenCallerHasListerRole + whenAssetHasAssetRole + whenStartTimeLtEndTime + whenStartTimeLtBlockTimestamp + whenStartTimeWithinHourOfBlockTimestamp + whenListingParamsAreValid + { + uint256 expectedListingId = 0; + + assertEq(DirectListingsLogic(marketplace).totalListings(), 0); + assertEq(DirectListingsLogic(marketplace).getListing(expectedListingId).assetContract, address(0)); + + IDirectListings.Listing memory listing; + + vm.prank(seller); + vm.expectEmit(true, true, true, false); + emit NewListing(seller, expectedListingId, listingParams.assetContract, listing); + DirectListingsLogic(marketplace).createListing(listingParams); + + listing = DirectListingsLogic(marketplace).getListing(expectedListingId); + assertEq(listing.assetContract, listingParams.assetContract); + assertEq(listing.tokenId, listingParams.tokenId); + assertEq(listing.quantity, listingParams.quantity); + assertEq(listing.currency, listingParams.currency); + assertEq(listing.pricePerToken, listingParams.pricePerToken); + assertEq(listing.endTimestamp, block.timestamp + (listingParams.endTimestamp - listingParams.startTimestamp)); + assertEq(listing.startTimestamp, block.timestamp); + assertEq(listing.listingCreator, seller); + assertEq(listing.reserved, true); + assertEq(uint256(listing.status), 1); // Status.CREATED + assertEq(uint256(listing.tokenType), 0); // TokenType.ERC721 + + assertEq(DirectListingsLogic(marketplace).totalListings(), 1); + assertEq(DirectListingsLogic(marketplace).getAllListings(0, 0).length, 1); + assertEq(DirectListingsLogic(marketplace).getAllValidListings(0, 0).length, 1); + } + + modifier whenStartTimeGteBlockTimestamp() { + vm.warp(listingParams.startTimestamp - 1 minutes); + _; + } + + function test_createListing_whenListingParamsAreInvalid_2() + public + whenCallerHasListerRole + whenAssetHasAssetRole + whenStartTimeLtEndTime + whenStartTimeGteBlockTimestamp + { + // This is one of the ways in which params are considered invalid. + // We've written separate BTT tests for `_validateNewListing` + listingParams.quantity = 0; + + vm.prank(seller); + vm.expectRevert("Marketplace: listing zero quantity."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + function test_createListing_whenListingParamsAreValid_2() + public + whenCallerHasListerRole + whenAssetHasAssetRole + whenStartTimeLtEndTime + whenStartTimeGteBlockTimestamp + whenListingParamsAreValid + { + uint256 expectedListingId = 0; + + assertEq(DirectListingsLogic(marketplace).totalListings(), 0); + assertEq(DirectListingsLogic(marketplace).getListing(expectedListingId).assetContract, address(0)); + + IDirectListings.Listing memory listing; + + vm.prank(seller); + vm.expectEmit(true, true, true, false); + emit NewListing(seller, expectedListingId, listingParams.assetContract, listing); + DirectListingsLogic(marketplace).createListing(listingParams); + + listing = DirectListingsLogic(marketplace).getListing(expectedListingId); + assertEq(listing.assetContract, listingParams.assetContract); + assertEq(listing.tokenId, listingParams.tokenId); + assertEq(listing.quantity, listingParams.quantity); + assertEq(listing.currency, listingParams.currency); + assertEq(listing.pricePerToken, listingParams.pricePerToken); + assertEq(listing.endTimestamp, listingParams.endTimestamp); + assertEq(listing.startTimestamp, listingParams.startTimestamp); + assertEq(listing.listingCreator, seller); + assertEq(listing.reserved, true); + assertEq(uint256(listing.status), 1); // Status.CREATED + assertEq(uint256(listing.tokenType), 0); // TokenType.ERC721 + + assertEq(DirectListingsLogic(marketplace).totalListings(), 1); + assertEq(DirectListingsLogic(marketplace).getAllListings(0, 0).length, 1); + assertEq(DirectListingsLogic(marketplace).getAllValidListings(0, 0).length, 0); + } +} diff --git a/src/test/marketplace/direct-listings/createListing/createListing.tree b/src/test/marketplace/direct-listings/createListing/createListing.tree new file mode 100644 index 000000000..8964c7798 --- /dev/null +++ b/src/test/marketplace/direct-listings/createListing/createListing.tree @@ -0,0 +1,27 @@ +function createListing(ListingParameters calldata _params) +├── when caller does not have LISTER_ROLE +│ └── it should revert +└── when the caller has lister LISTER_ROLE + ├── when the asset to list does not have ASSET_ROLE + │ └── it should revert + └── when the asset to list has ASSET_ROLE + ├── when the start time is greater i.e. after the end time + │ └── it should revert + └── when the start time is less than i.e. before the end time + ├── when the start time is less than i.e. before block timestamp + │ ├── when the start time is more than 60 minutes before block timestamp + │ │ └── it should revert + │ └── when the start time is less than or equal to 60 minutes before block timestamp + │ ├── when the listing params are invalid + │ │ └── it should revert + │ └── when the listing params are valid + │ ├── it should store the listing at a new listing ID + │ ├── it should return the listing ID + │ └── it should emit NewListing event with listing creator, listing ID, and listing data + └── when the start time is greater than i.e. after, or equal to block timestamp + ├── when the listing params are invalid + │ └── it should revert + └── when the listing params are valid + ├── it should store the listing at a new listing ID + ├── it should return the listing ID + └── it should emit NewListing event with listing creator, listing ID, and listing data \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/updateListing/updateListing.t.sol b/src/test/marketplace/direct-listings/updateListing/updateListing.t.sol new file mode 100644 index 000000000..6b35615ea --- /dev/null +++ b/src/test/marketplace/direct-listings/updateListing/updateListing.t.sol @@ -0,0 +1,502 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract UpdateListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + uint256 internal listingId = 0; + + // Events to test + + /// @notice Emitted when a listing is updated. + event UpdatedListing( + address indexed listingCreator, + uint256 indexed listingId, + address indexed assetContract, + IDirectListings.Listing listing + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100 minutes; + uint128 endTimestamp = 200 minutes; + bool reserved = true; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + function test_updateListing_whenListingDoesNotExist() public { + vm.prank(seller); + vm.expectRevert("Marketplace: invalid listing."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenListingExists() { + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + erc721.setApprovalForAll(marketplace, false); + vm.stopPrank(); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(erc721)); + _; + } + + function test_updateListing_whenAssetDoesntHaveAssetRole() public whenListingExists { + vm.prank(seller); + vm.expectRevert("!ASSET_ROLE"); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenAssetHasAssetRole() { + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + _; + } + + function test_updateListing_whenCallerIsNotListingCreator() public whenListingExists whenAssetHasAssetRole { + vm.prank(address(0x4567)); + vm.expectRevert("Marketplace: not listing creator."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenCallerIsListingCreator() { + _; + } + + function test_updateListing_whenListingHasExpired() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + { + vm.warp(listingParams.endTimestamp + 1); + + vm.prank(seller); + vm.expectRevert("Marketplace: listing expired."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenListingNotExpired() { + vm.warp(0); + _; + } + + function test_updateListing_whenUpdatedAssetIsDifferent() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + { + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + listingParams.assetContract = address(erc1155); + + vm.prank(seller); + vm.expectRevert("Marketplace: cannot update what token is listed."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + + listingParams.assetContract = address(erc721); + listingParams.tokenId = 10; + + vm.prank(seller); + vm.expectRevert("Marketplace: cannot update what token is listed."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenUpdatedAssetIsSame() { + _; + } + + function test_updateListing_whenUpdatedStartTimeGteEndTime() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + { + listingParams.startTimestamp = 200; + listingParams.endTimestamp = 100; + + vm.prank(seller); + vm.expectRevert("Marketplace: endTimestamp not greater than startTimestamp."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenUpdatedStartTimeLtUpdatedEndTime() { + _; + } + + function test_updateListing_whenUpdateMakesActiveListingInactive() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + { + vm.warp(listingParams.startTimestamp + 1); + + listingParams.startTimestamp += 50; + + vm.prank(seller); + vm.expectRevert("Marketplace: listing already active."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenUpdateDoesntMakeActiveListingInactive() { + _; + } + + modifier whenUpdatedStartIsDiffAndInPast() { + vm.warp(listingParams.startTimestamp - 1 minutes); + listingParams.startTimestamp -= 2 minutes; + _; + } + + function test_updateListing_whenUpdatedStartIsMoreThanHourInPast() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsDiffAndInPast + { + listingParams.startTimestamp = 30 minutes; + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid startTimestamp."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenUpdatedStartIsWithinPastHour() { + listingParams.startTimestamp = 90 minutes; + _; + } + + function test_updateListing_whenUpdatedPriceIsDifferentFromApprovedPrice_1() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsDiffAndInPast + whenUpdatedStartIsWithinPastHour + { + vm.prank(seller); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 2 ether); + + listingParams.currency = address(weth); + + vm.prank(seller); + vm.expectRevert("Marketplace: price different from approved price"); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenUpdatedPriceIsSameAsApprovedPrice() { + _; + } + + function test_updateListing_whenListingParamsAreInvalid_1() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsDiffAndInPast + whenUpdatedStartIsWithinPastHour + whenUpdatedPriceIsSameAsApprovedPrice + { + // This is one of the ways in which params can be invalid. + // Separate tests for `_validateNewListingParams` + listingParams.quantity = 0; + + vm.prank(seller); + vm.expectRevert("Marketplace: listing zero quantity."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenListingParamsAreValid() { + _; + } + + function test_updateListing_whenListingParamsAreValid_1() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsDiffAndInPast + whenUpdatedStartIsWithinPastHour + whenUpdatedPriceIsSameAsApprovedPrice + whenListingParamsAreValid + { + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + IDirectListings.Listing memory listing; + + vm.prank(seller); + vm.expectEmit(true, true, true, false); + emit UpdatedListing(seller, listingId, listingParams.assetContract, listing); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + + IDirectListings.Listing memory updatedListing = DirectListingsLogic(marketplace).getListing(listingId); + + assertEq(updatedListing.assetContract, listingParams.assetContract); + assertEq(updatedListing.tokenId, listingParams.tokenId); + assertEq(updatedListing.quantity, listingParams.quantity); + assertEq(updatedListing.currency, listingParams.currency); + assertEq(updatedListing.pricePerToken, listingParams.pricePerToken); + assertEq(updatedListing.endTimestamp, listingParams.endTimestamp); + assertEq(updatedListing.startTimestamp, block.timestamp); + assertEq(updatedListing.listingCreator, seller); + assertEq(updatedListing.reserved, true); + assertEq(uint256(updatedListing.status), 1); // Status.CREATED + assertEq(uint256(updatedListing.tokenType), 0); // TokenType.ERC721 + } + + modifier whenUpdatedStartIsSameAsCurrentStart() { + _; + } + + function test_updateListing_whenUpdatedPriceIsDifferentFromApprovedPrice_2() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsSameAsCurrentStart + { + vm.prank(seller); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 2 ether); + + listingParams.currency = address(weth); + + vm.prank(seller); + vm.expectRevert("Marketplace: price different from approved price"); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + function test_updateListing_whenListingParamsAreInvalid_2() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsSameAsCurrentStart + whenUpdatedPriceIsSameAsApprovedPrice + { + // This is one of the ways in which params can be invalid. + // Separate tests for `_validateNewListingParams` + listingParams.quantity = 0; + + vm.prank(seller); + vm.expectRevert("Marketplace: listing zero quantity."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + function test_updateListing_whenListingParamsAreValid_2() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsSameAsCurrentStart + whenUpdatedPriceIsSameAsApprovedPrice + whenListingParamsAreValid + { + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + IDirectListings.Listing memory listing; + + vm.prank(seller); + vm.expectEmit(true, true, true, false); + emit UpdatedListing(seller, listingId, listingParams.assetContract, listing); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + + IDirectListings.Listing memory updatedListing = DirectListingsLogic(marketplace).getListing(listingId); + + assertEq(updatedListing.assetContract, listingParams.assetContract); + assertEq(updatedListing.tokenId, listingParams.tokenId); + assertEq(updatedListing.quantity, listingParams.quantity); + assertEq(updatedListing.currency, listingParams.currency); + assertEq(updatedListing.pricePerToken, listingParams.pricePerToken); + assertEq(updatedListing.endTimestamp, listingParams.endTimestamp); + assertEq(updatedListing.startTimestamp, listingParams.startTimestamp); + assertEq(updatedListing.listingCreator, seller); + assertEq(updatedListing.reserved, true); + assertEq(uint256(updatedListing.status), 1); // Status.CREATED + assertEq(uint256(updatedListing.tokenType), 0); // TokenType.ERC721 + } +} diff --git a/src/test/marketplace/direct-listings/updateListing/updateListing.tree b/src/test/marketplace/direct-listings/updateListing/updateListing.tree new file mode 100644 index 000000000..982ed1076 --- /dev/null +++ b/src/test/marketplace/direct-listings/updateListing/updateListing.tree @@ -0,0 +1,43 @@ +function updateListing(uint256 _listingId, ListingParameters memory _params) +├── when the listing does not exist +│ └── it should revert ✅ +└── when listing exists + ├── when asset does not have ASSET_ROLE + │ └── it should revert ✅ + └── when asset has ASSET_ROLE + ├── when caller is not listing creator + │ └── it should revert ✅ + └── when caller is listing creator + ├── when listing has expired + │ └── it should revert ✅ + └── when listing has not expired + ├── when the updated asset is different from the listed asset + │ └── it should revert ✅ + └── when the updated asset is the same as the listed asset + ├── when the updated start time is greater or equal to than the updated end time + │ └── it should revert ✅ + └── when the updated start time is less than the updated end time + ├── when update makes active listing inactive + │ └── it should revert ✅ + └── when update does not make active listing inactive + ├── when the updated start time is in the past and different from the listed start time + │ ├── when the updated start time is more than 60 minutes before block timestamp + │ │ └── it should revert ✅ + │ └── when the updated start time is within 60 minutes past block timestamp + │ ├── when updated price in updated currency different from approved price for updated currency + │ │ └── it should revert ✅ + │ └── when updated price in updated currency is same as approved price for updated currency + │ ├── when updated listing params are invalid + │ │ └── it should revert ✅ + │ └── when updated listing params are valid ✅ + │ ├── it should store updated listing at the same listing ID + │ └── it should emit UpdatedListing event with listing creator, listing ID, updated asset contract and listing data + └── when the updated start time is same as listed start time + ├── when updated price in updated currency different from approved price for updated currency + │ └── it should revert ✅ + └── when updated price in updated currency is same as approved price for updated currency + ├── when updated listing params are invalid + │ └── it should revert ✅ + └── when updated listing params are valid ✅ + ├── it should store updated listing at the same listing ID + └── it should emit UpdatedListing event with listing creator, listing ID, updated asset contract and listing data \ No newline at end of file