From 9e6b0d1e1a450392fe3677666087b6637de7ab5b Mon Sep 17 00:00:00 2001 From: MichaelGrinstead Date: Thu, 23 Nov 2023 13:20:12 +1000 Subject: [PATCH] feat: Add Marketplace contract --- moda-contracts/lib/forge-std | 2 +- .../lib/openzeppelin-contracts-upgradeable | 2 +- .../lib/openzeppelin-foundry-upgrades | 2 +- moda-contracts/src/Catalog.sol | 14 +- moda-contracts/src/Marketplace.sol | 248 ++++++++ moda-contracts/src/Releases.sol | 4 - .../src/interfaces/IMarketplace.sol | 135 ++++ moda-contracts/test/Catalog.t.sol | 2 +- moda-contracts/test/Marketplace.t.sol | 595 ++++++++++++++++++ moda-contracts/test/mocks/ERC20TokenMock.sol | 10 + 10 files changed, 999 insertions(+), 15 deletions(-) create mode 100644 moda-contracts/src/Marketplace.sol create mode 100644 moda-contracts/src/interfaces/IMarketplace.sol create mode 100644 moda-contracts/test/Marketplace.t.sol create mode 100644 moda-contracts/test/mocks/ERC20TokenMock.sol diff --git a/moda-contracts/lib/forge-std b/moda-contracts/lib/forge-std index 2f112697..87a2a0af 160000 --- a/moda-contracts/lib/forge-std +++ b/moda-contracts/lib/forge-std @@ -1 +1 @@ -Subproject commit 2f112697506eab12d433a65fdc31a639548fe365 +Subproject commit 87a2a0afc5fafd6297538a45a52ac19e71a84562 diff --git a/moda-contracts/lib/openzeppelin-contracts-upgradeable b/moda-contracts/lib/openzeppelin-contracts-upgradeable index 4ca003c9..67b51f0e 160000 --- a/moda-contracts/lib/openzeppelin-contracts-upgradeable +++ b/moda-contracts/lib/openzeppelin-contracts-upgradeable @@ -1 +1 @@ -Subproject commit 4ca003c9635d2c16756cf8c9db6760e2d3653dee +Subproject commit 67b51f0e69a558a3ce3997c74905d49741b1f504 diff --git a/moda-contracts/lib/openzeppelin-foundry-upgrades b/moda-contracts/lib/openzeppelin-foundry-upgrades index 5d1cbcdb..6ef16a63 160000 --- a/moda-contracts/lib/openzeppelin-foundry-upgrades +++ b/moda-contracts/lib/openzeppelin-foundry-upgrades @@ -1 +1 @@ -Subproject commit 5d1cbcdbf079071b84dfafd8843ef76d48c57a77 +Subproject commit 6ef16a6390514e964845fb4740a75071b960320d diff --git a/moda-contracts/src/Catalog.sol b/moda-contracts/src/Catalog.sol index 2c443dc1..49252de3 100644 --- a/moda-contracts/src/Catalog.sol +++ b/moda-contracts/src/Catalog.sol @@ -36,7 +36,7 @@ contract Catalog is ICatalog, AccessControlUpgradeable { /// @dev releasesOwner => releases mapping(address => address) _registeredReleasesContracts; /// @dev release => releaseOwner - mapping(address => address) _registeredReleasesOwners; + mapping(address => address) _registeredReleasesOwner; /// @dev releaseHash => RegisteredRelease mapping(bytes32 => RegisteredRelease) _registeredReleases; /// @dev releases => tokenId => tracks on release @@ -255,7 +255,7 @@ contract Catalog is ICatalog, AccessControlUpgradeable { _requireReleasesContractNotRegistered(releases); $._registeredReleasesContracts[releasesOwner] = releases; - $._registeredReleasesOwners[releases] = releasesOwner; + $._registeredReleasesOwner[releases] = releasesOwner; emit ReleasesRegistered(releases, releasesOwner); } @@ -264,9 +264,9 @@ contract Catalog is ICatalog, AccessControlUpgradeable { CatalogStorage storage $ = _getCatalogStorage(); _requireReleasesContractIsRegistered(releases); - address releasesOwner = $._registeredReleasesOwners[releases]; + address releasesOwner = $._registeredReleasesOwner[releases]; delete $._registeredReleasesContracts[releasesOwner]; - delete $._registeredReleasesOwners[releases]; + delete $._registeredReleasesOwner[releases]; emit ReleasesUnregistered(releases, releasesOwner); } @@ -274,7 +274,7 @@ contract Catalog is ICatalog, AccessControlUpgradeable { function getReleasesOwner(address releases) external view returns (address owner) { CatalogStorage storage $ = _getCatalogStorage(); - return $._registeredReleasesOwners[releases]; + return $._registeredReleasesOwner[releases]; } /// @inheritdoc IReleasesRegistration @@ -488,7 +488,7 @@ contract Catalog is ICatalog, AccessControlUpgradeable { function _requireReleasesContractIsRegistered(address releases) internal view { CatalogStorage storage $ = _getCatalogStorage(); - if ($._registeredReleasesOwners[releases] == address(0)) { + if ($._registeredReleasesOwner[releases] == address(0)) { revert ReleasesContractIsNotRegistered(); } } @@ -496,7 +496,7 @@ contract Catalog is ICatalog, AccessControlUpgradeable { function _requireReleasesContractNotRegistered(address releases) internal view { CatalogStorage storage $ = _getCatalogStorage(); - if ($._registeredReleasesOwners[releases] != address(0)) { + if ($._registeredReleasesOwner[releases] != address(0)) { revert ReleasesContractIsAlreadyRegistered(); } } diff --git a/moda-contracts/src/Marketplace.sol b/moda-contracts/src/Marketplace.sol new file mode 100644 index 00000000..7e8ee375 --- /dev/null +++ b/moda-contracts/src/Marketplace.sol @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import "../src/interfaces/IMarketplace.sol"; +import "../src/interfaces/Releases/IReleasesRegistration.sol"; +import "../src/interfaces/Releases/IReleases.sol"; + +/** + * @title Marketplace + * @dev This contract allows buying and selling of Releases and charges a fee on each sale. + */ +contract Marketplace is IMarketplace, ERC1155Holder, ReentrancyGuard, AccessControl { + using SafeERC20 for IERC20; + + // State Variables + + IERC20 _token; + IReleasesRegistration _catalog; + address payable public treasury; + uint256 public treasuryFee; + + /// @dev releaseOwner => saleId => Sale + mapping(address => Sale[]) private sales; + + // Errors + error CannotBeZeroAddress(); + error TreasuryFeeCannotBeZero(); + error TokenAmountCannotBeZero(); + error MaxCountCannotBeZero(); + error StartCannotBeAfterEnd(uint256 startTime, uint256 endTime); + error InsufficientSupply(uint256 remainingSupply); + error MaxSupplyReached(uint256 maxSupplyPerWallet); + error SaleNotStarted(uint256 startTime); + error SaleHasEnded(uint256 endTime, uint256 currentTime); + error ReleasesIsNotRegistered(); + error OnlySellerCanWithdraw(); + + /** + * @dev Constructor + * @param treasury_ - The address of the organizations treasury + * @param treasuryFee_ - The percentage that will be transferred + * to the treasury on each sale. Based on a denominator of 10_000 e.g. 1000 = 10% + * @param token - A token that implements an IERC20 interface that will be used for payments + * @param catalog - A contract that implements the IReleasesRegistration interface + */ + constructor( + address payable treasury_, + uint256 treasuryFee_, + IERC20 token, + IReleasesRegistration catalog + ) { + if (address(token) == address(0)) revert CannotBeZeroAddress(); + if (treasuryFee_ == 0) revert TreasuryFeeCannotBeZero(); + if (treasury_ == address(0)) revert CannotBeZeroAddress(); + treasury = treasury_; + treasuryFee = treasuryFee_; + _token = token; + _catalog = catalog; + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + // External Functions + + /** + * @inheritdoc IMarketplace + */ + function createSale( + address releaseOwner, + address payable beneficiary, + IReleases releases, + uint256 tokenId, + uint256 amountTotal, + uint256 pricePerToken, + uint256 startAt, + uint256 endAt, + uint256 maxCountPerWallet + ) external { + if (IReleasesRegistration(_catalog).getReleasesOwner(address(releases)) == address(0)) { + revert ReleasesIsNotRegistered(); + } + if (beneficiary == address(0)) revert CannotBeZeroAddress(); + if (amountTotal == 0) revert TokenAmountCannotBeZero(); + if (endAt != 0 && startAt > endAt) { + revert StartCannotBeAfterEnd(startAt, endAt); + } + if (maxCountPerWallet == 0) revert MaxCountCannotBeZero(); + IERC1155(address(releases)).safeTransferFrom( + _msgSender(), address(this), tokenId, amountTotal, "" + ); + + sales[releaseOwner].push( + Sale({ + seller: _msgSender(), + releaseOwner: releaseOwner, + beneficiary: beneficiary, + releases: address(releases), + tokenId: tokenId, + amountRemaining: amountTotal, + amountTotal: amountTotal, + pricePerToken: pricePerToken, + startAt: startAt, + endAt: endAt, + maxCountPerWallet: maxCountPerWallet + }) + ); + + emit SaleCreated(releaseOwner, sales[releaseOwner].length - 1); + } + + /** + * @inheritdoc IMarketplace + */ + function purchase( + address releaseOwner, + uint256 saleId, + uint256 tokenAmount, + address recipient + ) external nonReentrant { + Sale storage sale = _getSaleForPurchase(releaseOwner, saleId, tokenAmount); + + uint256 totalPrice = sale.pricePerToken * tokenAmount; + uint256 fee = (treasuryFee * totalPrice) / 10_000; + _token.safeTransferFrom(_msgSender(), address(this), totalPrice); + _token.transfer(treasury, fee); + _token.transfer(sale.beneficiary, totalPrice - fee); + + _transferTokens(sale.releases, sale.tokenId, tokenAmount, recipient); + + sale.amountRemaining -= tokenAmount; + + emit Purchase( + sale.releases, sale.tokenId, recipient, releaseOwner, saleId, tokenAmount, block.timestamp + ); + } + + /** + * @inheritdoc IMarketplace + */ + function withdraw(address releaseOwner, uint256 saleId, uint256 tokenAmount) external nonReentrant { + if (tokenAmount == 0) revert TokenAmountCannotBeZero(); + Sale storage sale = sales[releaseOwner][saleId]; + if (_msgSender() != sale.seller) revert OnlySellerCanWithdraw(); + if (tokenAmount > sale.amountRemaining) { + revert InsufficientSupply(sale.amountRemaining); + } + _transferTokens(sale.releases, sale.tokenId, tokenAmount, _msgSender()); + + sale.amountRemaining -= tokenAmount; + + emit Withdraw(_msgSender(), saleId, tokenAmount); + } + + /** + * @inheritdoc IMarketplace + */ + function getSale(address releaseOwner, uint256 saleId) external view returns (Sale memory) { + return sales[releaseOwner][saleId]; + } + + /** + * @inheritdoc IMarketplace + */ + function saleCount(address releaseOwner) external view returns (uint256) { + return sales[releaseOwner].length; + } + + /** + * @inheritdoc IMarketplace + */ + function setTreasuryFee(uint256 newFee) external onlyRole(DEFAULT_ADMIN_ROLE) { + treasuryFee = newFee; + } + + /** + * @inheritdoc IMarketplace + */ + function setTreasury(address payable newTreasury) external onlyRole(DEFAULT_ADMIN_ROLE) { + treasury = newTreasury; + } + + // Public Functions + + /** + * @inheritdoc ERC165 + */ + function supportsInterface(bytes4 interfaceId) + public + view + override(AccessControl, ERC1155Holder) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + // Internal Functions + + /** + * @dev Verifies the purchase process for a sale + * @param releaseOwner - The address of the releaseOwner + * @param saleId - The id of the sale + * @param tokenAmount - The amount of tokens to purchase + */ + function _getSaleForPurchase( + address releaseOwner, + uint256 saleId, + uint256 tokenAmount + ) internal view returns (Sale storage) { + Sale storage sale = sales[releaseOwner][saleId]; + if (sale.startAt > block.timestamp) { + revert SaleNotStarted(sale.startAt); + } + if (sale.endAt != 0 && sale.endAt < block.timestamp) { + revert SaleHasEnded(sale.endAt, block.timestamp); + } + if (tokenAmount == 0) revert TokenAmountCannotBeZero(); + if (tokenAmount > sale.amountRemaining) { + revert InsufficientSupply(sale.amountRemaining); + } + + uint256 buyerBalance = IERC1155(sale.releases).balanceOf(_msgSender(), sale.tokenId); + if ((buyerBalance + tokenAmount) > sale.maxCountPerWallet) { + revert MaxSupplyReached(sale.maxCountPerWallet); + } + return sale; + } + + /** + * @dev Transfers Release tokens from the contract to the recipient + * @param releases - The address of the Releases contract + * @param tokenId - The id of the token + * @param tokenAmount - The amount of tokens to transfer + * @param recipient - The address that will receive the Tokens + */ + function _transferTokens( + address releases, + uint256 tokenId, + uint256 tokenAmount, + address recipient + ) internal { + IERC1155(releases).safeTransferFrom(address(this), recipient, tokenId, tokenAmount, ""); + } +} diff --git a/moda-contracts/src/Releases.sol b/moda-contracts/src/Releases.sol index c9fbe777..174b292e 100644 --- a/moda-contracts/src/Releases.sol +++ b/moda-contracts/src/Releases.sol @@ -12,8 +12,6 @@ import {IWithdrawRelease} from "./interfaces/Releases/IWithdrawRelease.sol"; import {ICatalog} from "./interfaces/Catalog/ICatalog.sol"; import {ISplitsFactory} from "./interfaces/ISplitsFactory.sol"; - - /** * @title Releases * @dev This contract allows artists or labels to create their own release tokens. @@ -78,9 +76,7 @@ contract Releases is address[] calldata releaseAdmins, string calldata name_, string calldata symbol_, - ICatalog catalog, - ISplitsFactory splitsFactory ) external initializer { __ERC1155_init(""); diff --git a/moda-contracts/src/interfaces/IMarketplace.sol b/moda-contracts/src/interfaces/IMarketplace.sol new file mode 100644 index 00000000..e6ba8e78 --- /dev/null +++ b/moda-contracts/src/interfaces/IMarketplace.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import "./Releases/IReleases.sol"; + +/** + * @title IMarketplace + * @dev Interface for the Marketplace + */ +interface IMarketplace { + /** + * @dev Represents a created Sale for a release + * seller - The address of the seller + * releaseOwner - The address of the release owner/seller + * beneficiary - The address that will receive the funds once the sale is completed + * releases - The address of the Releases contract that the release belongs to + * TokenId - The token id of the release + * AmountRemaining - The amount of tokens remaining to be sold + * AmountTotal - The total amount of tokens to be sold + * PricePerToken - The price per token + * StartAt - The start time of the sale + * EndAt - The end time of the sale, set to 0 for no end time + * MaxCountPerWallet - The maximum amount of tokens that can be purchased per wallet + */ + struct Sale { + address seller; + address releaseOwner; + address payable beneficiary; + address releases; + uint256 tokenId; + uint256 amountRemaining; + uint256 amountTotal; + uint256 pricePerToken; + uint256 startAt; + uint256 endAt; + uint256 maxCountPerWallet; + } + + /** + * @dev Emitted when a sale is created + */ + event SaleCreated(address indexed releaseOwner, uint256 indexed saleId); + + /** + * @dev Emitted when a sale is purchased + */ + event Purchase( + address indexed releases, + uint256 indexed tokenId, + address indexed buyer, + address releaseOwner, + uint256 saleId, + uint256 tokenAmount, + uint256 timestamp + ); + + /** + * @dev Emitted when a sale is withdrawn + */ + event Withdraw(address indexed recipient, uint256 indexed saleId, uint256 tokenAmount); + + /** + * @dev Create a sale for a given release. The tokens must come from a releases contract + * that has been registered in the Catalog and they must be in the sellers wallet. + * @param releaseOwner - The address of the release owner + * @param beneficiary - The address that will receive the funds once the sale is completed + * @param releases - A contract that implements the IReleases interface + * @param tokenId - The id of the Token + * @param amountTotal - The amount of tokens to sell + * @param pricePerToken - The price per token + * @param startAt - The start time of the sale + * @param endAt - The end time of the sale, set to 0 for no end time + * @param maxCountPerWallet - The maximum amount of tokens that can be purchased per wallet + */ + function createSale( + address releaseOwner, + address payable beneficiary, + IReleases releases, + uint256 tokenId, + uint256 amountTotal, + uint256 pricePerToken, + uint256 startAt, + uint256 endAt, + uint256 maxCountPerWallet + ) external; + + /** + * @dev Purchase a release. Payment is in USDC. + * @param releaseOwner - The address of the release owner + * @param saleId - The id of the sale + * @param tokenAmount - The amount of tokens to purchase + * @param recipient - The address that will receive the Tokens + */ + function purchase( + address releaseOwner, + uint256 saleId, + uint256 tokenAmount, + address recipient + ) external; + + /** + * @dev Withdraw a Release. Caller must own the release. + * @param releaseOwner - The address of the release owner + * @param saleId - The id of the sale + * @param tokenAmount - The amount of tokens to withdraw + */ + function withdraw(address releaseOwner, uint256 saleId, uint256 tokenAmount) external; + + /** + * @dev Returns a Sale + * @param releaseOwner - The address of the release owner + * @param saleId - The id of the sale + */ + function getSale(address releaseOwner, uint256 saleId) external view returns (Sale memory); + + /** + * @dev Returns the number of sales for a given release owner + * @param releaseOwner - The address of the release owner + */ + function saleCount(address releaseOwner) external view returns (uint256); + + /** + * @dev Sets the treasury fee + * @notice Caller must have DEFAULT_ADMIN_ROLE + * @param newFee - The new treasury fee + */ + function setTreasuryFee(uint256 newFee) external; + + /** + * @dev Sets the treasury address + * @notice Caller must have DEFAULT_ADMIN_ROLE + * @param newTreasury - The new treasury address + */ + function setTreasury(address payable newTreasury) external; +} diff --git a/moda-contracts/test/Catalog.t.sol b/moda-contracts/test/Catalog.t.sol index 58e04e7c..9a7796bd 100644 --- a/moda-contracts/test/Catalog.t.sol +++ b/moda-contracts/test/Catalog.t.sol @@ -167,7 +167,7 @@ contract CatalogTest is Test { function test_registerTrack_emits_event() public { vm.expectEmit(true, true, true, true); - emit TrackRegistered("trackHash", "ACME-CATALOG-31337-0", artist); + emit TrackRegistered("trackHash", "ACME.CATALOG-31337-0", artist); vm.startPrank(artist); catalog.registerTrack( artist, trackRegistrationData.trackBeneficiary, trackRegistrationData.trackRegistrationHash diff --git a/moda-contracts/test/Marketplace.t.sol b/moda-contracts/test/Marketplace.t.sol new file mode 100644 index 00000000..51f1803d --- /dev/null +++ b/moda-contracts/test/Marketplace.t.sol @@ -0,0 +1,595 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.21; + +import {Test, console2} from "forge-std/Test.sol"; +import "../src/ModaRegistry.sol"; +import "../src/CatalogFactory.sol"; +import "../src/Catalog.sol"; +import "../src/Releases.sol"; +import "../src/Management.sol"; +import "../test/mocks/MembershipMock.sol"; +import "../test/mocks/SplitsFactoryMock.sol"; +import "../src/ReleasesFactory.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; +import "../src/Marketplace.sol"; +import "./mocks/ERC20TokenMock.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; + +contract MarketplaceTest is Test { + Membership public membership; + Management public management; + ModaRegistry public modaRegistry; + SplitsFactoryMock public splitsFactory; + CatalogFactory public catalogFactory; + Catalog public catalog; + Releases public releasesMaster; + ReleasesFactory public releasesFactory; + Releases public releases; + Marketplace public marketplace; + ERC20TokenMock public token; + + address catalogBeacon; + address modaAdmin = address(0xa); + string public catalogName = "TestCatalog"; + + string name = "TestReleases"; + string symbol = "TEST"; + address admin = address(0x6); + address releaseAdmin = address(0x1); + address[] releaseAdmins = [releaseAdmin]; + address releaseOwner = address(0x2); + address payable treasury = payable(address(0x5)); + + address buyer = address(0x7); + + address payable saleBeneficiary = payable(address(0x8)); + uint256 saleAmountTotal = 100; + uint256 salePricePerToken = 20; + uint256 saleStartAt = block.timestamp; + uint256 saleEndAt = 0; + uint256 saleMaxCountPerWallet = 10; + + event SaleCreated(address indexed releaseOwner, uint256 indexed saleId); + + event Purchase( + address indexed releases, + uint256 indexed tokenId, + address indexed buyer, + address releaseOwner, + uint256 saleId, + uint256 tokenAmount, + uint256 timestamp + ); + event Withdraw(address indexed recipient, uint256 indexed saleId, uint256 tokenAmount); + + function setUp() public { + token = new ERC20TokenMock(); + management = new Management(); + membership = new Membership(); + splitsFactory = new SplitsFactoryMock(address(0x3)); + modaRegistry = new ModaRegistry(treasury, 1000); + modaRegistry.setManagement(management); + modaRegistry.setSplitsFactory(splitsFactory); + + catalogBeacon = Upgrades.deployBeacon("Catalog.sol", modaAdmin); + catalogFactory = new CatalogFactory(modaRegistry, catalogBeacon); + modaRegistry.grantRole(keccak256("CATALOG_REGISTRAR_ROLE"), address(catalogFactory)); + + catalog = Catalog(catalogFactory.create(catalogName, IMembership(membership))); + + membership.addMember(releaseAdmin); + releasesMaster = new Releases(); + releasesFactory = new ReleasesFactory(address(modaRegistry), address(releasesMaster)); + modaRegistry.grantRole(keccak256("RELEASES_REGISTRAR_ROLE"), address(releasesFactory)); + + vm.startPrank(admin); + releasesFactory.create(releaseAdmins, name, symbol, catalog); + vm.stopPrank(); + + releases = Releases(catalog.getReleasesContract(admin)); + marketplace = new Marketplace(treasury, 1000, token, catalog); + + vm.startPrank(buyer); + token.mint(buyer, 1000); + token.approve(address(marketplace), 1000); + vm.stopPrank(); + } + + // Constructor + + function test_constructor() public { + assertEq(marketplace.treasury(), treasury); + assertEq(marketplace.treasuryFee(), 1000); + assertEq(marketplace.hasRole(0x00, address(this)), true); + } + + // Create Sale + + function createSale_setUp() public { + vm.startPrank(releaseAdmin); + + catalog.registerTrack(releaseAdmin, address(0x9), "registrationHash"); + + catalog.setReleasesApprovalForAll(releaseAdmin, address(releases), true); + + string memory trackId = catalog.getTrackId("registrationHash"); + string[] memory trackIds = new string[](1); + trackIds[0] = trackId; + releases.create(releaseAdmin, 1000, "uri", 100, trackIds); + + uint256 releaseAdminBalance = releases.balanceOf(releaseAdmin, 1); + + console2.log("releaseAdminBalance", releaseAdminBalance); + + releases.setApprovalForAll(address(marketplace), true); + + vm.stopPrank(); + } + + function test_createSale() public { + createSale_setUp(); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, + saleBeneficiary, + releases, + 1, + saleAmountTotal, + salePricePerToken, + saleStartAt, + saleEndAt, + saleMaxCountPerWallet + ); + vm.stopPrank(); + + uint256 marketplaceBalance = releases.balanceOf(address(marketplace), 1); + Marketplace.Sale memory sale = marketplace.getSale(releaseOwner, 0); + + assertEq(marketplaceBalance, 100); + assertEq(sale.releaseOwner, releaseOwner); + assertEq(sale.beneficiary, saleBeneficiary); + assertEq(sale.releases, address(releases)); + assertEq(sale.tokenId, 1); + assertEq(sale.amountRemaining, saleAmountTotal); + assertEq(sale.amountTotal, saleAmountTotal); + assertEq(sale.pricePerToken, salePricePerToken); + assertEq(sale.startAt, saleStartAt); + assertEq(sale.endAt, saleEndAt); + assertEq(sale.maxCountPerWallet, saleMaxCountPerWallet); + } + + function test_createSale_RevertIf_Releases_is_not_registered() public { + createSale_setUp(); + + address unregisteredReleases = address(0x9); + + vm.expectRevert(Marketplace.ReleasesIsNotRegistered.selector); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, + saleBeneficiary, + IReleases(unregisteredReleases), + 1, + saleAmountTotal, + salePricePerToken, + saleStartAt, + saleEndAt, + saleMaxCountPerWallet + ); + vm.stopPrank(); + } + + function test_createSale_RevertIf_beneficiary_address_is_zero() public { + createSale_setUp(); + + vm.expectRevert(Marketplace.CannotBeZeroAddress.selector); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, + payable(address(0x0)), + releases, + 1, + saleAmountTotal, + salePricePerToken, + saleStartAt, + saleEndAt, + saleMaxCountPerWallet + ); + vm.stopPrank(); + } + + function test_createSale_RevertIf_amountTotal_is_zero() public { + createSale_setUp(); + + vm.expectRevert(Marketplace.TokenAmountCannotBeZero.selector); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, + saleBeneficiary, + releases, + 1, + 0, + salePricePerToken, + saleStartAt, + saleEndAt, + saleMaxCountPerWallet + ); + vm.stopPrank(); + } + + function test_createSale_RevertIf_startAt_is_after_endAt() public { + createSale_setUp(); + + vm.expectRevert(abi.encodeWithSelector(Marketplace.StartCannotBeAfterEnd.selector, 2, 1)); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, + saleBeneficiary, + releases, + 1, + saleAmountTotal, + salePricePerToken, + 2, + 1, + saleMaxCountPerWallet + ); + vm.stopPrank(); + } + + function test_createSale_RevertIf_maxCountPerWallet_is_zero() public { + createSale_setUp(); + + vm.expectRevert(Marketplace.MaxCountCannotBeZero.selector); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, + saleBeneficiary, + releases, + 1, + saleAmountTotal, + salePricePerToken, + saleStartAt, + saleEndAt, + 0 + ); + vm.stopPrank(); + } + + function test_createSale_emits_event() public { + createSale_setUp(); + + vm.expectEmit(true, true, true, true); + emit SaleCreated(releaseOwner, 0); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, + saleBeneficiary, + releases, + 1, + saleAmountTotal, + salePricePerToken, + saleStartAt, + saleEndAt, + saleMaxCountPerWallet + ); + vm.stopPrank(); + } + + // Purchase + + function purchase_setUp() public { + createSale_setUp(); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, + saleBeneficiary, + releases, + 1, + saleAmountTotal, + salePricePerToken, + saleStartAt, + saleEndAt, + saleMaxCountPerWallet + ); + vm.stopPrank(); + } + + function test_purchase() public { + purchase_setUp(); + + vm.startPrank(buyer); + marketplace.purchase(releaseOwner, 0, 10, buyer); + vm.stopPrank(); + + uint256 marketplaceBalance = releases.balanceOf(address(marketplace), 1); + uint256 amountRemaining = marketplace.getSale(releaseOwner, 0).amountRemaining; + uint256 buyerBalance = releases.balanceOf(buyer, 1); + uint256 buyerUsdcBalance = token.balanceOf(buyer); + + assertEq(marketplaceBalance, 90); + assertEq(amountRemaining, 90); + assertEq(buyerBalance, 10); + assertEq(buyerUsdcBalance, 800); + } + + function test_purchase_RevertIf_Sale_has_not_started() public { + createSale_setUp(); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, + saleBeneficiary, + releases, + 1, + saleAmountTotal, + salePricePerToken, + block.timestamp + 100, + saleEndAt, + saleMaxCountPerWallet + ); + vm.stopPrank(); + + vm.expectRevert( + abi.encodeWithSelector(Marketplace.SaleNotStarted.selector, block.timestamp + 100) + ); + + vm.startPrank(buyer); + marketplace.purchase(releaseOwner, 0, 10, buyer); + vm.stopPrank(); + } + + function test_purchase_RevertIf_Sale_has_ended() public { + createSale_setUp(); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, + saleBeneficiary, + releases, + 1, + saleAmountTotal, + salePricePerToken, + block.timestamp, + block.timestamp + 1, + saleMaxCountPerWallet + ); + vm.stopPrank(); + + vm.warp(block.timestamp + 2); + + vm.expectRevert( + abi.encodeWithSelector( + Marketplace.SaleHasEnded.selector, block.timestamp - 1, block.timestamp + ) + ); + + vm.startPrank(buyer); + marketplace.purchase(releaseOwner, 0, 10, buyer); + vm.stopPrank(); + } + + function test_purchase_RevertIf_tokenAmount_is_zero() public { + purchase_setUp(); + + vm.expectRevert(Marketplace.TokenAmountCannotBeZero.selector); + + vm.startPrank(buyer); + marketplace.purchase(releaseOwner, 0, 0, buyer); + vm.stopPrank(); + } + + function test_purchase_RevertIf_tokenAmount_is_greater_than_amountRemaining() public { + purchase_setUp(); + + vm.expectRevert(abi.encodeWithSelector(Marketplace.InsufficientSupply.selector, 100)); + + vm.startPrank(buyer); + marketplace.purchase(releaseOwner, 0, 101, buyer); + vm.stopPrank(); + } + + function test_purchase_RevertIf_maxCountPerWallet_is_exceeded() public { + purchase_setUp(); + + vm.startPrank(buyer); + marketplace.purchase(releaseOwner, 0, 10, buyer); + vm.stopPrank(); + + vm.expectRevert(abi.encodeWithSelector(Marketplace.MaxSupplyReached.selector, 10)); + + vm.startPrank(buyer); + marketplace.purchase(releaseOwner, 0, 1, buyer); + vm.stopPrank(); + } + + function test_purchase_emits_event() public { + purchase_setUp(); + + vm.expectEmit(true, true, true, true); + emit Purchase(address(releases), 1, buyer, releaseOwner, 0, 10, block.timestamp); + + vm.startPrank(buyer); + marketplace.purchase(releaseOwner, 0, 10, buyer); + vm.stopPrank(); + } + + // Create Sale and Purchase with no end time + + function test_createSale_and_purchase_with_no_end_time() public { + createSale_setUp(); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, saleBeneficiary, releases, 1, 100, 20, block.timestamp, 0, 10 + ); + vm.stopPrank(); + + vm.startPrank(buyer); + marketplace.purchase(releaseOwner, 0, 10, buyer); + vm.stopPrank(); + + uint256 marketplaceBalance = releases.balanceOf(address(marketplace), 1); + uint256 amountRemaining = marketplace.getSale(releaseOwner, 0).amountRemaining; + uint256 buyerBalance = releases.balanceOf(buyer, 1); + uint256 buyerUsdcBalance = token.balanceOf(buyer); + + assertEq(marketplaceBalance, 90); + assertEq(amountRemaining, 90); + assertEq(buyerBalance, 10); + assertEq(buyerUsdcBalance, 800); + } + + // Withdraw + + function withdraw_setUp() public { + createSale_setUp(); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, + saleBeneficiary, + releases, + 1, + saleAmountTotal, + salePricePerToken, + saleStartAt, + saleEndAt, + saleMaxCountPerWallet + ); + vm.stopPrank(); + } + + function test_withdraw() public { + withdraw_setUp(); + + vm.startPrank(releaseAdmin); + marketplace.withdraw(releaseOwner, 0, 10); + vm.stopPrank(); + + uint256 marketplaceBalance = releases.balanceOf(address(marketplace), 1); + uint256 releaseAdminBalance = releases.balanceOf(releaseAdmin, 1); + uint256 amountRemaining = marketplace.getSale(releaseOwner, 0).amountRemaining; + + assertEq(marketplaceBalance, 90); + assertEq(releaseAdminBalance, 10); + assertEq(amountRemaining, 90); + } + + function test_withdraw_RevertIf_if_caller_is_not_seller() public { + withdraw_setUp(); + + address unauthorizedCaller = address(0x9); + + vm.expectRevert(Marketplace.OnlySellerCanWithdraw.selector); + + vm.startPrank(unauthorizedCaller); + marketplace.withdraw(releaseOwner, 0, 10); + vm.stopPrank(); + } + + function test_withdraw_RevertIf_tokenAmount_is_zero() public { + withdraw_setUp(); + + vm.expectRevert(Marketplace.TokenAmountCannotBeZero.selector); + + vm.startPrank(releaseAdmin); + marketplace.withdraw(releaseOwner, 0, 0); + vm.stopPrank(); + } + + function test_withdraw_RevertIf_tokenAmount_is_greater_than_amountRemaining() public { + withdraw_setUp(); + + vm.expectRevert(abi.encodeWithSelector(Marketplace.InsufficientSupply.selector, 100)); + + vm.startPrank(releaseAdmin); + marketplace.withdraw(releaseOwner, 0, 101); + vm.stopPrank(); + } + + function test_withdraw_emits_event() public { + withdraw_setUp(); + + vm.expectEmit(true, true, true, true); + emit Withdraw(releaseAdmin, 0, 10); + + vm.startPrank(releaseAdmin); + marketplace.withdraw(releaseOwner, 0, 10); + vm.stopPrank(); + } + + // Sale Count + + function test_saleCount() public { + createSale_setUp(); + + vm.startPrank(releaseAdmin); + marketplace.createSale( + releaseOwner, + saleBeneficiary, + releases, + 1, + saleAmountTotal, + salePricePerToken, + saleStartAt, + saleEndAt, + saleMaxCountPerWallet + ); + vm.stopPrank(); + + uint256 saleCount = marketplace.saleCount(releaseOwner); + + assertEq(saleCount, 1); + } + + // Treasury + + function test_setTreasury() public { + address payable newTreasury = payable(address(0x9)); + marketplace.setTreasury(newTreasury); + + assertEq(marketplace.treasury(), newTreasury); + } + + function test_setTreasury_RevertIf_caller_does_not_have_ADMIN_ROLE() public { + address unauthorizedCaller = address(0x9); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, unauthorizedCaller, 0x00 + ) + ); + + vm.startPrank(unauthorizedCaller); + marketplace.setTreasury(payable(address(0x9))); + vm.stopPrank(); + } + + // Treasury fee + + function test_setTreasuryFee() public { + marketplace.setTreasuryFee(500); + + assertEq(marketplace.treasuryFee(), 500); + } + + function test_setTreasuryFee_RevertIf_caller_does_not_have_ADMIN_ROLE() public { + address unauthorizedCaller = address(0x9); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, unauthorizedCaller, 0x00 + ) + ); + + vm.startPrank(unauthorizedCaller); + marketplace.setTreasuryFee(500); + vm.stopPrank(); + } +} diff --git a/moda-contracts/test/mocks/ERC20TokenMock.sol b/moda-contracts/test/mocks/ERC20TokenMock.sol new file mode 100644 index 00000000..84c5cb59 --- /dev/null +++ b/moda-contracts/test/mocks/ERC20TokenMock.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.21; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract ERC20TokenMock is ERC20("token", "TOKEN") { + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +}