diff --git a/contracts/OriumMarketplace.sol b/contracts/OriumMarketplace.sol index ea9b6c5..41f98a0 100644 --- a/contracts/OriumMarketplace.sol +++ b/contracts/OriumMarketplace.sol @@ -3,21 +3,28 @@ pragma solidity 0.8.9; import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { IERC1155 } from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; - +import { EIP712Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/cryptography/draft-EIP712Upgradeable.sol"; +import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; /** * @title Orium Marketplace - Marketplace for renting NFTs * @dev This contract is used to manage NFTs rentals, powered by ERC-7432 Non-Fungible Token Roles * @author Orium Network Team - developers@orium.network */ -contract OriumMarketplace is Initializable, OwnableUpgradeable, PausableUpgradeable { +contract OriumMarketplace is Initializable, OwnableUpgradeable, PausableUpgradeable, EIP712Upgradeable { + /** ######### Constants ########### **/ + /// @dev 100 ether is 100% uint256 public constant MAX_PERCENTAGE = 100 ether; /// @dev 2.5 ether is 2.5% uint256 public constant DEFAULT_FEE_PERCENTAGE = 2.5 ether; + + /** ######### Global Variables ########### **/ + /// @dev rolesRegistry is a ERC-7432 contract address public rolesRegistry; /// @dev deadline is set in seconds @@ -29,6 +36,14 @@ contract OriumMarketplace is Initializable, OwnableUpgradeable, PausableUpgradea /// @dev tokenAddress => royaltyInfo mapping(address => RoyaltyInfo) public royaltyInfo; + /// @dev nonce => hashedOffer => isPresigned + mapping(uint256 => mapping(bytes32 => bool)) public preSignedOffer; + + /// @dev lender => nonce => bool + mapping(address => mapping(uint256 => uint256)) public nonceDeadline; + + /** ######### Structs ########### **/ + /// @dev Royalty info. Used to charge fees for the creator. struct RoyaltyInfo { address creator; @@ -42,14 +57,91 @@ contract OriumMarketplace is Initializable, OwnableUpgradeable, PausableUpgradea bool isCustomFee; } + /// @dev Rental offer info. + struct RentalOffer { + address lender; + address borrower; + address tokenAddress; + uint256 tokenId; + address feeTokenAddress; + uint256 feeAmountPerSecond; + uint256 nonce; + uint64 deadline; + bytes32[] roles; + bytes[] rolesData; + } + + /** ######### Events ########### **/ + + /** + * @param tokenAddress The NFT address. + * @param feePercentageInWei The fee percentage in wei. + * @param isCustomFee If the fee is custom or not. Used to allow collections with no fee. + */ event MarketplaceFeeSet(address indexed tokenAddress, uint256 feePercentageInWei, bool isCustomFee); + /** + * @param tokenAddress The NFT address. + * @param creator The address of the creator. + * @param royaltyPercentageInWei The royalty percentage in wei. + * @param treasury The address where the fees will be sent. If the treasury is address(0), the fees will be burned. + */ event CreatorRoyaltySet( address indexed tokenAddress, address indexed creator, uint256 royaltyPercentageInWei, address treasury ); + /** + * @param nonce The nonce of the rental offer + * @param lender The address of the user lending the NFT + * @param borrower The address of the user renting the NFT + * @param tokenAddress The address of the contract of the NFT to rent + * @param tokenId The tokenId of the NFT to rent + * @param feeTokenAddress The address of the ERC20 token for rental fees + * @param feeAmountPerSecond The amount of fee per second + * @param deadline The deadline until when the rental offer is valid + * @param roles The array of roles to be assigned to the borrower + * @param rolesData The array of data for each role + */ + event RentalOfferCreated( + uint256 indexed nonce, + address indexed lender, + address borrower, + address tokenAddress, + uint256 tokenId, + address feeTokenAddress, + uint256 feeAmountPerSecond, + uint256 deadline, + bytes32[] roles, + bytes[] rolesData + ); + + /** ######### Modifiers ########### **/ + + /** + * @notice Checks the ownership of the token. + * @dev Throws if the caller is not the owner of the token. + * @param _tokenAddress The NFT address. + * @param _tokenId The id of the token. + */ + modifier onlyTokenOwner(address _tokenAddress, uint256 _tokenId) { + if (isERC1155(_tokenAddress)) { + require( + IERC1155(_tokenAddress).balanceOf(msg.sender, _tokenId) > 0, + "OriumMarketplace: only token owner can call this function" + ); + } else if(isERC721(_tokenAddress)) { + require( + msg.sender == IERC721(_tokenAddress).ownerOf(_tokenId), + "OriumMarketplace: only token owner can call this function" + ); + } else { + revert("OriumMarketplace: token address is not ERC1155 or ERC721"); + } + _; + } + /** ######### Initializer ########### **/ /** * @notice Initializes the contract. * @dev The owner of the contract will be the owner of the protocol. @@ -67,10 +159,86 @@ contract OriumMarketplace is Initializable, OwnableUpgradeable, PausableUpgradea transferOwnership(_owner); } - function marketplaceFeeOf(address _tokenAddress) public view returns (uint256) { - return feeInfo[_tokenAddress].isCustomFee ? feeInfo[_tokenAddress].feePercentageInWei : DEFAULT_FEE_PERCENTAGE; + /** ============================ Rental Functions ================================== **/ + + /** ######### Setters ########### **/ + /** + * @notice Creates a rental offer. + * @dev To optimize for gas, only the offer hash is stored on-chain + * @param _offer The rental offer struct. + */ + function createRentalOffer( + RentalOffer calldata _offer + ) external onlyTokenOwner(_offer.tokenAddress, _offer.tokenId) { + require(msg.sender == _offer.lender, "OriumMarketplace: Sender and Lender mismatch"); + require( + _offer.roles.length == _offer.rolesData.length, + "OriumMarketplace: roles and rolesData should have the same length" + ); + require( + _offer.deadline <= block.timestamp + maxDeadline && _offer.deadline > block.timestamp, + "OriumMarketplace: Invalid deadline" + ); + require(nonceDeadline[_offer.lender][_offer.nonce] == 0, "OriumMarketplace: Nonce already used"); + + nonceDeadline[_offer.lender][_offer.nonce] = _offer.deadline; + preSignedOffer[_offer.nonce][hashRentalOffer(_offer)] = true; + + emit RentalOfferCreated( + _offer.nonce, + _offer.lender, + _offer.borrower, + _offer.tokenAddress, + _offer.tokenId, + _offer.feeTokenAddress, + _offer.feeAmountPerSecond, + _offer.deadline, + _offer.roles, + _offer.rolesData + ); + } + + /** ######### Getters ########### **/ + + /** + * @notice Gets the rental offer hash. + * @param _offer The rental offer struct to be hashed. + */ + function hashRentalOffer(RentalOffer memory _offer) public view returns (bytes32) { + return + _hashTypedDataV4( + keccak256( + abi.encode( + keccak256( + "RentalOffer(address lender,address borrower,address tokenAddress,uint256 tokenId,address feeTokenAddress,uint256 feeAmountPerSecond,uint256 nonce,uint64 deadline,bytes32[] roles,bytes[] rolesData)" + ), + _offer.lender, + _offer.borrower, + _offer.tokenAddress, + _offer.tokenId, + _offer.feeTokenAddress, + _offer.feeAmountPerSecond, + _offer.nonce, + _offer.deadline, + _offer.roles, + _offer.rolesData + ) + ) + ); + } + + function isERC1155(address _tokenAddress) public view returns (bool) { + return ERC165Checker.supportsInterface(_tokenAddress, type(IERC1155).interfaceId); + } + + function isERC721(address _tokenAddress) public view returns (bool) { + return ERC165Checker.supportsInterface(_tokenAddress, type(IERC721).interfaceId); } + /** ============================ Core Functions ================================== **/ + + /** ######### Setters ########### **/ + /** * @notice Sets the roles registry. * @dev Only owner can set the roles registry. @@ -90,7 +258,7 @@ contract OriumMarketplace is Initializable, OwnableUpgradeable, PausableUpgradea /** * @notice Sets the marketplace fee for a collection. * @dev If no fee is set, the default fee will be used. - * @param _tokenAddress The address of the collection. + * @param _tokenAddress The NFT address. * @param _feePercentageInWei The fee percentage in wei. * @param _isCustomFee If the fee is custom or not. */ @@ -113,8 +281,8 @@ contract OriumMarketplace is Initializable, OwnableUpgradeable, PausableUpgradea /** * @notice Sets the royalty info. * @dev Only owner can associate a collection with a creator. - * @param _tokenAddress The address of the collection. -cd * @param _creator The address of the creator. + * @param _tokenAddress The NFT address. + * @param _creator The address of the creator. */ function setCreator(address _tokenAddress, address _creator) external onlyOwner { _setRoyalty(_creator, _tokenAddress, 0, address(0)); @@ -122,8 +290,8 @@ cd * @param _creator The address of the creator. /** * @notice Sets the royalty info. - * @param _tokenAddress The address of the collection. - * @param _royaltyPercentageInWei The royalty percentage in wei. If the fee is 0, the creator fee will be disabled. + * @param _tokenAddress The NFT address. + * @param _royaltyPercentageInWei The royalty percentage in wei. * @param _treasury The address where the fees will be sent. If the treasury is address(0), the fees will be burned. */ function setRoyaltyInfo(address _tokenAddress, uint256 _royaltyPercentageInWei, address _treasury) external { @@ -135,6 +303,14 @@ cd * @param _creator The address of the creator. _setRoyalty(msg.sender, _tokenAddress, _royaltyPercentageInWei, _treasury); } + /** + * @notice Sets the royalty info. + * @dev Only owner can associate a collection with a creator. + * @param _creator The address of the creator. + * @param _tokenAddress The NFT address. + * @param _royaltyPercentageInWei The royalty percentage in wei. + * @param _treasury The address where the fees will be sent. If the treasury is address(0), the fees will be burned. + */ function _setRoyalty( address _creator, address _tokenAddress, @@ -164,4 +340,15 @@ cd * @param _creator The address of the creator. require(_maxDeadline > 0, "OriumMarketplace: Max deadline should be greater than 0"); maxDeadline = _maxDeadline; } + + /** ######### Getters ########### **/ + + /** + * @notice Gets the marketplace fee for a collection. + * @dev If no custom fee is set, the default fee will be used. + * @param _tokenAddress The NFT address. + */ + function marketplaceFeeOf(address _tokenAddress) public view returns (uint256) { + return feeInfo[_tokenAddress].isCustomFee ? feeInfo[_tokenAddress].feePercentageInWei : DEFAULT_FEE_PERCENTAGE; + } } diff --git a/contracts/mocks/MockERC1155.sol b/contracts/mocks/MockERC1155.sol new file mode 100644 index 0000000..0e52eee --- /dev/null +++ b/contracts/mocks/MockERC1155.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.9; + +import { ERC1155 } from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; + +/** + * @title MockERC1155 + * @dev Mock contract for testing purposes. + */ + +contract MockERC1155 is ERC1155 { + constructor() ERC1155("") {} + + function mint(address to, uint256 tokenId, uint256 amount, bytes memory data) external { + _mint(to, tokenId, amount, data); + } + + function burn(address account, uint256 tokenId, uint256 amount) external { + _burn(account, tokenId, amount); + } +} \ No newline at end of file diff --git a/contracts/mocks/MockERC20.sol b/contracts/mocks/MockERC20.sol new file mode 100644 index 0000000..675ac31 --- /dev/null +++ b/contracts/mocks/MockERC20.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.9; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * @title MockERC20 + * @dev Mock contract for testing purposes. + */ + +contract MockERC20 is ERC20 { + constructor() ERC20("PaymentToken", "PAY") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} \ No newline at end of file diff --git a/contracts/mocks/MockNft.sol b/contracts/mocks/MockERC721.sol similarity index 88% rename from contracts/mocks/MockNft.sol rename to contracts/mocks/MockERC721.sol index 031b117..08ed4cf 100644 --- a/contracts/mocks/MockNft.sol +++ b/contracts/mocks/MockERC721.sol @@ -5,11 +5,11 @@ pragma solidity 0.8.9; import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; /** - * @title MockNft + * @title MockERC721 * @dev Mock contract for testing purposes. */ -contract MockNft is ERC721 { +contract MockERC721 is ERC721 { constructor() ERC721("MockNft", "MOCK") {} function mint(address to, uint256 tokenId) external { diff --git a/test/OriumMarketplace.test.ts b/test/OriumMarketplace.test.ts index 4d52a2e..5b01eac 100644 --- a/test/OriumMarketplace.test.ts +++ b/test/OriumMarketplace.test.ts @@ -5,11 +5,15 @@ import { loadFixture } from '@nomicfoundation/hardhat-network-helpers' import { deployMarketplaceContracts } from './fixtures/OriumMarketplaceFixture' import { expect } from 'chai' import { toWei } from '../utils/utils' -import { FeeInfo, RoyaltyInfo } from '../utils/types' +import { FeeInfo, RentalOffer, RoyaltyInfo } from '../utils/types' +import { ONE_DAY } from '../utils/constants' +import { randomBytes } from 'crypto' describe('OriumMarketplace', () => { let marketplace: Contract - let nft: Contract + let mockERC721: Contract + let mockERC20: Contract + let mockERC1155: Contract // We are disabling this rule because hardhat uses first account as deployer by default, and we are separating deployer and operator // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -18,6 +22,8 @@ describe('OriumMarketplace', () => { let notOperator: SignerWithAddress let creator: SignerWithAddress let creatorTreasury: SignerWithAddress + let lender: SignerWithAddress + let borrower: SignerWithAddress // Values to be used across tests const maxDeadline = 1000 @@ -29,182 +35,286 @@ describe('OriumMarketplace', () => { before(async function () { // we are disabling this rule so ; may not be added automatically by prettier at the beginning of the line // prettier-ignore - [deployer, operator, notOperator, creator, creatorTreasury] = await ethers.getSigners() + [deployer, operator, notOperator, creator, creatorTreasury, lender, borrower] = await ethers.getSigners() }) beforeEach(async () => { // we are disabling this rule so ; may not be added automatically by prettier at the beginning of the line // prettier-ignore - [marketplace, nft] = await loadFixture(deployMarketplaceContracts) + [marketplace, mockERC721, mockERC20, mockERC1155] = await loadFixture(deployMarketplaceContracts) }) describe('Main Functions', async () => { - describe('Initialize', async () => { - it("Should NOT initialize the contract if it's already initialized", async () => { - await expect(marketplace.initialize(operator.address, ethers.constants.AddressZero, 0)).to.be.revertedWith( - 'Initializable: contract is already initialized', - ) - }) - }) - describe('Pausable', async () => { - describe('Pause', async () => { - it('Should pause the contract', async () => { - await marketplace.connect(operator).pause() - expect(await marketplace.paused()).to.be.true - }) + describe('Rental Functions', async () => { + describe('Create Rental Offer', async () => { + let rentalOffer: RentalOffer + const tokenId = 1 - it('Should NOT pause the contract if caller is not the operator', async () => { - await expect(marketplace.connect(notOperator).pause()).to.be.revertedWith('Ownable: caller is not the owner') + beforeEach(async () => { + await mockERC721.mint(lender.address, tokenId) + const blockTimestamp = (await ethers.provider.getBlock('latest')).timestamp + rentalOffer = { + nonce: `0x${randomBytes(32).toString('hex')}`, + lender: lender.address, + borrower: borrower.address, + tokenAddress: mockERC721.address, + tokenId, + feeTokenAddress: mockERC20.address, + feeAmountPerSecond: ethers.BigNumber.from(0), + deadline: blockTimestamp + ONE_DAY, + roles: [], + rolesData: [], + } }) - }) - describe('Unpause', async () => { - it('Should unpause the contract', async () => { - await marketplace.connect(operator).pause() - await marketplace.connect(operator).unpause() - expect(await marketplace.paused()).to.be.false + it('Should create a rental offer for ERC721', async () => { + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)) + .to.emit(marketplace, 'RentalOfferCreated') + .withArgs( + rentalOffer.nonce, + rentalOffer.lender, + rentalOffer.borrower, + rentalOffer.tokenAddress, + rentalOffer.tokenId, + rentalOffer.feeTokenAddress, + rentalOffer.feeAmountPerSecond, + rentalOffer.deadline, + rentalOffer.roles, + rentalOffer.rolesData, + ) }) - - it('Should NOT unpause the contract if caller is not the operator', async () => { - await marketplace.connect(operator).pause() - await expect(marketplace.connect(notOperator).unpause()).to.be.revertedWith( - 'Ownable: caller is not the owner', + it('Should create a rental offer for ERC1155', async () => { + await mockERC1155.mint(lender.address, tokenId, 1, []) + rentalOffer.tokenAddress = mockERC1155.address + rentalOffer.tokenId = tokenId + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)) + .to.emit(marketplace, 'RentalOfferCreated') + .withArgs( + rentalOffer.nonce, + rentalOffer.lender, + rentalOffer.borrower, + rentalOffer.tokenAddress, + rentalOffer.tokenId, + rentalOffer.feeTokenAddress, + rentalOffer.feeAmountPerSecond, + rentalOffer.deadline, + rentalOffer.roles, + rentalOffer.rolesData, + ) + }) + it('Should NOT create a rental offer if caller is not the lender', async () => { + await expect(marketplace.connect(notOperator).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumMarketplace: only token owner can call this function', + ) + }) + it("Should NOT create a rental offer if lender is not the caller's address", async () => { + rentalOffer.lender = creator.address + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumMarketplace: Sender and Lender mismatch', + ) + }) + it("Should NOT create a rental offer if roles and rolesData don't have the same length", async () => { + rentalOffer.roles = [`0x${randomBytes(32).toString('hex')}`] + rentalOffer.rolesData = [`0x${randomBytes(32).toString('hex')}`, `0x${randomBytes(32).toString('hex')}`] + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumMarketplace: roles and rolesData should have the same length', + ) + }) + it('Should NOT create a rental offer if deadline is greater than maxDeadline', async () => { + rentalOffer.deadline = maxDeadline + 1 + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumMarketplace: Invalid deadline', + ) + }) + it("Should NOT create a rental offer if deadline is less than block's timestamp", async () => { + rentalOffer.deadline = (await ethers.provider.getBlock('latest')).timestamp - 1 + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumMarketplace: Invalid deadline', + ) + }) + it('Should NOT create a rental offer if nonce is already used', async () => { + await marketplace.connect(lender).createRentalOffer(rentalOffer) + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumMarketplace: Nonce already used', + ) + }) + it('Should NOT create a rental offer if NFT is neither ERC721 nor ERC1155', async () => { + rentalOffer.tokenAddress = ethers.constants.AddressZero + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumMarketplace: token address is not ERC1155 or ERC721', ) }) }) }) - describe('Marketplace Fee', async () => { - it('Should set the marketplace for a collection', async () => { - await expect( - marketplace - .connect(operator) - .setMarketplaceFeeForCollection(nft.address, feeInfo.feePercentageInWei, feeInfo.isCustomFee), - ) - .to.emit(marketplace, 'MarketplaceFeeSet') - .withArgs(nft.address, feeInfo.feePercentageInWei, feeInfo.isCustomFee) - expect(await marketplace.feeInfo(nft.address)).to.have.deep.members([ - feeInfo.feePercentageInWei, - feeInfo.isCustomFee, - ]) - expect(await marketplace.marketplaceFeeOf(nft.address)).to.be.equal(feeInfo.feePercentageInWei) - }) - it('Should NOT set the marketplace fee if caller is not the operator', async () => { - await expect( - marketplace - .connect(notOperator) - .setMarketplaceFeeForCollection(nft.address, feeInfo.feePercentageInWei, feeInfo.isCustomFee), - ).to.be.revertedWith('Ownable: caller is not the owner') - }) - it("Should NOT set the marketplace fee if marketplace fee + creator royalty it's greater than 100%", async () => { - await marketplace.connect(operator).setCreator(nft.address, creator.address) - - const royaltyInfo: RoyaltyInfo = { - creator: creator.address, - royaltyPercentageInWei: toWei('10'), - treasury: creatorTreasury.address, - } - - await marketplace - .connect(creator) - .setRoyaltyInfo(nft.address, royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury) - - const feeInfo: FeeInfo = { - feePercentageInWei: toWei('95'), - isCustomFee: true, - } - await expect( - marketplace - .connect(operator) - .setMarketplaceFeeForCollection(nft.address, feeInfo.feePercentageInWei, feeInfo.isCustomFee), - ).to.be.revertedWith('OriumMarketplace: Royalty percentage + marketplace fee cannot be greater than 100%') - }) - }) - describe('Creator Royalties', async () => { - describe('Operator', async () => { - it('Should set the creator royalties for a collection', async () => { - const royaltyInfo: RoyaltyInfo = { - creator: creator.address, - royaltyPercentageInWei: toWei('0'), - treasury: ethers.constants.AddressZero, - } - - await expect(marketplace.connect(operator).setCreator(nft.address, creator.address)) - .to.emit(marketplace, 'CreatorRoyaltySet') - .withArgs(nft.address, creator.address, royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury) - - expect(await marketplace.royaltyInfo(nft.address)).to.have.deep.members([ - royaltyInfo.creator, - royaltyInfo.royaltyPercentageInWei, - royaltyInfo.treasury, - ]) - }) - it('Should NOT set the creator royalties if caller is not the operator', async () => { - await expect(marketplace.connect(notOperator).setCreator(nft.address, creator.address)).to.be.revertedWith( - 'Ownable: caller is not the owner', + describe('Core Functions', async () => { + describe('Initialize', async () => { + it("Should NOT initialize the contract if it's already initialized", async () => { + await expect(marketplace.initialize(operator.address, ethers.constants.AddressZero, 0)).to.be.revertedWith( + 'Initializable: contract is already initialized', ) }) }) + describe('Pausable', async () => { + describe('Pause', async () => { + it('Should pause the contract', async () => { + await marketplace.connect(operator).pause() + expect(await marketplace.paused()).to.be.true + }) - describe('Creator', async () => { - beforeEach(async () => { - await marketplace.connect(operator).setCreator(nft.address, creator.address) + it('Should NOT pause the contract if caller is not the operator', async () => { + await expect(marketplace.connect(notOperator).pause()).to.be.revertedWith( + 'Ownable: caller is not the owner', + ) + }) }) - it("Should update the creator royalties for a collection if it's already set", async () => { - const royaltyInfo: RoyaltyInfo = { - creator: creator.address, - royaltyPercentageInWei: toWei('0'), - treasury: creatorTreasury.address, - } + describe('Unpause', async () => { + it('Should unpause the contract', async () => { + await marketplace.connect(operator).pause() + await marketplace.connect(operator).unpause() + expect(await marketplace.paused()).to.be.false + }) + it('Should NOT unpause the contract if caller is not the operator', async () => { + await marketplace.connect(operator).pause() + await expect(marketplace.connect(notOperator).unpause()).to.be.revertedWith( + 'Ownable: caller is not the owner', + ) + }) + }) + }) + describe('Marketplace Fee', async () => { + it('Should set the marketplace for a collection', async () => { await expect( marketplace - .connect(creator) - .setRoyaltyInfo(nft.address, royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury), + .connect(operator) + .setMarketplaceFeeForCollection(mockERC721.address, feeInfo.feePercentageInWei, feeInfo.isCustomFee), ) - .to.emit(marketplace, 'CreatorRoyaltySet') - .withArgs(nft.address, creator.address, royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury) + .to.emit(marketplace, 'MarketplaceFeeSet') + .withArgs(mockERC721.address, feeInfo.feePercentageInWei, feeInfo.isCustomFee) + expect(await marketplace.feeInfo(mockERC721.address)).to.have.deep.members([ + feeInfo.feePercentageInWei, + feeInfo.isCustomFee, + ]) + expect(await marketplace.marketplaceFeeOf(mockERC721.address)).to.be.equal(feeInfo.feePercentageInWei) }) - it('Should NOT update the creator royalties for a collection if caller is not the creator', async () => { - const royaltyInfo: RoyaltyInfo = { - creator: creator.address, - royaltyPercentageInWei: toWei('0'), - treasury: creatorTreasury.address, - } - + it('Should NOT set the marketplace fee if caller is not the operator', async () => { await expect( marketplace .connect(notOperator) - .setRoyaltyInfo(nft.address, royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury), - ).to.be.revertedWith('OriumMarketplace: Only creator can set royalty info') + .setMarketplaceFeeForCollection(mockERC721.address, feeInfo.feePercentageInWei, feeInfo.isCustomFee), + ).to.be.revertedWith('Ownable: caller is not the owner') }) - it("Should NOT update the creator royalties for a collection if creator's royalty percentage + marketplace fee is greater than 100%", async () => { + it("Should NOT set the marketplace fee if marketplace fee + creator royalty it's greater than 100%", async () => { + await marketplace.connect(operator).setCreator(mockERC721.address, creator.address) + const royaltyInfo: RoyaltyInfo = { creator: creator.address, - royaltyPercentageInWei: toWei('99'), + royaltyPercentageInWei: toWei('10'), treasury: creatorTreasury.address, } + await marketplace + .connect(creator) + .setRoyaltyInfo(mockERC721.address, royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury) + + const feeInfo: FeeInfo = { + feePercentageInWei: toWei('95'), + isCustomFee: true, + } await expect( marketplace - .connect(creator) - .setRoyaltyInfo(nft.address, royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury), + .connect(operator) + .setMarketplaceFeeForCollection(mockERC721.address, feeInfo.feePercentageInWei, feeInfo.isCustomFee), ).to.be.revertedWith('OriumMarketplace: Royalty percentage + marketplace fee cannot be greater than 100%') }) }) - }) - describe('Max Deadline', async () => { - it('Should set the max deadline by operator', async () => { - await marketplace.connect(operator).setMaxDeadline(maxDeadline) - expect(await marketplace.maxDeadline()).to.be.equal(maxDeadline) - }) - it('Should NOT set the max deadline if caller is not the operator', async () => { - await expect(marketplace.connect(notOperator).setMaxDeadline(maxDeadline)).to.be.revertedWith( - 'Ownable: caller is not the owner', - ) + describe('Creator Royalties', async () => { + describe('Operator', async () => { + it('Should set the creator royalties for a collection', async () => { + const royaltyInfo: RoyaltyInfo = { + creator: creator.address, + royaltyPercentageInWei: toWei('0'), + treasury: ethers.constants.AddressZero, + } + + await expect(marketplace.connect(operator).setCreator(mockERC721.address, creator.address)) + .to.emit(marketplace, 'CreatorRoyaltySet') + .withArgs(mockERC721.address, creator.address, royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury) + + expect(await marketplace.royaltyInfo(mockERC721.address)).to.have.deep.members([ + royaltyInfo.creator, + royaltyInfo.royaltyPercentageInWei, + royaltyInfo.treasury, + ]) + }) + it('Should NOT set the creator royalties if caller is not the operator', async () => { + await expect( + marketplace.connect(notOperator).setCreator(mockERC721.address, creator.address), + ).to.be.revertedWith('Ownable: caller is not the owner') + }) + }) + + describe('Creator', async () => { + beforeEach(async () => { + await marketplace.connect(operator).setCreator(mockERC721.address, creator.address) + }) + it("Should update the creator royalties for a collection if it's already set", async () => { + const royaltyInfo: RoyaltyInfo = { + creator: creator.address, + royaltyPercentageInWei: toWei('0'), + treasury: creatorTreasury.address, + } + + await expect( + marketplace + .connect(creator) + .setRoyaltyInfo(mockERC721.address, royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury), + ) + .to.emit(marketplace, 'CreatorRoyaltySet') + .withArgs(mockERC721.address, creator.address, royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury) + }) + it('Should NOT update the creator royalties for a collection if caller is not the creator', async () => { + const royaltyInfo: RoyaltyInfo = { + creator: creator.address, + royaltyPercentageInWei: toWei('0'), + treasury: creatorTreasury.address, + } + + await expect( + marketplace + .connect(notOperator) + .setRoyaltyInfo(mockERC721.address, royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury), + ).to.be.revertedWith('OriumMarketplace: Only creator can set royalty info') + }) + it("Should NOT update the creator royalties for a collection if creator's royalty percentage + marketplace fee is greater than 100%", async () => { + const royaltyInfo: RoyaltyInfo = { + creator: creator.address, + royaltyPercentageInWei: toWei('99'), + treasury: creatorTreasury.address, + } + + await expect( + marketplace + .connect(creator) + .setRoyaltyInfo(mockERC721.address, royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury), + ).to.be.revertedWith('OriumMarketplace: Royalty percentage + marketplace fee cannot be greater than 100%') + }) + }) }) - it('Should NOT set the max deadline 0', async () => { - await expect(marketplace.connect(operator).setMaxDeadline(0)).to.be.revertedWith( - 'OriumMarketplace: Max deadline should be greater than 0', - ) + describe('Max Deadline', async () => { + it('Should set the max deadline by operator', async () => { + await marketplace.connect(operator).setMaxDeadline(maxDeadline) + expect(await marketplace.maxDeadline()).to.be.equal(maxDeadline) + }) + it('Should NOT set the max deadline if caller is not the operator', async () => { + await expect(marketplace.connect(notOperator).setMaxDeadline(maxDeadline)).to.be.revertedWith( + 'Ownable: caller is not the owner', + ) + }) + it('Should NOT set the max deadline 0', async () => { + await expect(marketplace.connect(operator).setMaxDeadline(0)).to.be.revertedWith( + 'OriumMarketplace: Max deadline should be greater than 0', + ) + }) }) }) }) diff --git a/test/fixtures/OriumMarketplaceFixture.ts b/test/fixtures/OriumMarketplaceFixture.ts index 0be2452..a2b4fa1 100644 --- a/test/fixtures/OriumMarketplaceFixture.ts +++ b/test/fixtures/OriumMarketplaceFixture.ts @@ -4,7 +4,7 @@ import { Contract } from 'ethers' /** * @dev deployer and operator needs to be the first two accounts in the hardhat ethers.getSigners() * list respectively. This should be considered to use this fixture in tests - * @returns [marketplace, nft] // TODO: TBD add rolesRegistry to the return + * @returns [marketplace, mockERC721, mockERC20, mockERC1155] */ export async function deployMarketplaceContracts() { const [, operator] = await ethers.getSigners() @@ -19,9 +19,17 @@ export async function deployMarketplaceContracts() { ]) await marketplace.deployed() - const NftFactory = await ethers.getContractFactory('MockNft') - const nft = await NftFactory.deploy() - await nft.deployed() + const MockERC721Factory = await ethers.getContractFactory('MockERC721') + const mockERC721 = await MockERC721Factory.deploy() + await mockERC721.deployed() - return [marketplace, nft] as Contract[] + const MockERC20Factory = await ethers.getContractFactory('MockERC20') + const mockERC20 = await MockERC20Factory.deploy() + await mockERC20.deployed() + + const MockERC1155Factory = await ethers.getContractFactory('MockERC1155') + const mockERC1155 = await MockERC1155Factory.deploy() + await mockERC1155.deployed() + + return [marketplace, mockERC721, mockERC20, mockERC1155] as Contract[] } diff --git a/utils/types.ts b/utils/types.ts index 78452d5..8cc88eb 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -10,3 +10,16 @@ export interface RoyaltyInfo { royaltyPercentageInWei: BigNumber treasury: string } + +export interface RentalOffer { + nonce: string + lender: string + borrower: string + tokenAddress: string + tokenId: number + feeTokenAddress: string + feeAmountPerSecond: BigNumber + deadline: number + roles: string[] + rolesData: string[] +}