From 2cd2ffd9c602db69070b1b9586c6db951628e61a Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Tue, 16 Apr 2024 16:16:23 -0300 Subject: [PATCH 01/11] ON-812: ERC721 Marketplace - Create Rental Offer --- contracts/OriumNftMarketplace.sol | 212 ++++++++++++++++ hardhat.config.ts | 8 +- test/OriumNftMarketplace.test.ts | 254 ++++++++++++++++++++ test/fixtures/OriumNftMarketplaceFixture.ts | 48 ++++ utils/types.ts | 1 + 5 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 contracts/OriumNftMarketplace.sol create mode 100644 test/OriumNftMarketplace.test.ts create mode 100644 test/fixtures/OriumNftMarketplaceFixture.ts diff --git a/contracts/OriumNftMarketplace.sol b/contracts/OriumNftMarketplace.sol new file mode 100644 index 0000000..a602172 --- /dev/null +++ b/contracts/OriumNftMarketplace.sol @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.9; + +import { IERC721 } from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; +import { IOriumMarketplaceRoyalties } from './interfaces/IOriumMarketplaceRoyalties.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'; + +/** + * @title Orium NFT 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 OriumNftMarketplace is Initializable, OwnableUpgradeable, PausableUpgradeable { + /** ######### Global Variables ########### **/ + + /// @dev oriumMarketplaceRoyalties stores the collection royalties and fees + address public oriumMarketplaceRoyalties; + + /// @dev hashedOffer => bool + mapping(bytes32 => bool) public isCreated; + + /// @dev lender => nonce => deadline + mapping(address => mapping(uint256 => uint64)) public nonceDeadline; + + /// @dev role => tokenAddress => tokenId => deadline + mapping(bytes32 => mapping(address => mapping(uint256 => uint64))) public roleDeadline; + + /** ######### Structs ########### **/ + + /// @dev Rental offer info. + struct RentalOffer { + address lender; + address borrower; + address tokenAddress; + uint256 tokenId; + address feeTokenAddress; + uint256 feeAmountPerSecond; + uint256 nonce; + uint64 deadline; + uint64 minDuration; + bytes32[] roles; + bytes[] rolesData; + } + + /** ######### Events ########### **/ + + /** + * @param nonce The nonce of the rental offer + * @param tokenAddress The address of the contract of the NFT to rent + * @param tokenId The tokenId of the NFT to rent + * @param lender The address of the user lending the NFT + * @param borrower The address of the user renting the NFT + * @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 tokenAddress, + uint256 indexed tokenId, + address lender, + address borrower, + address feeTokenAddress, + uint256 feeAmountPerSecond, + uint256 deadline, + uint64 minDuration, + 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) { + require( + msg.sender == IERC721(_tokenAddress).ownerOf(_tokenId), + 'OriumNftMarketplace: only token owner can call this function' + ); + _; + } + + /** ######### Initializer ########### **/ + /** + * @notice Initializes the contract. + * @dev The owner of the contract will be the owner of the protocol. + * @param _owner The owner of the protocol. + * @param _oriumMarketplaceRoyalties The address of the OriumMarketplaceRoyalties contract. + */ + function initialize(address _owner, address _oriumMarketplaceRoyalties) public initializer { + __Pausable_init(); + __Ownable_init(); + + oriumMarketplaceRoyalties = _oriumMarketplaceRoyalties; + + transferOwnership(_owner); + } + + /** ============================ 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) { + _validateCreateRentalOffer(_offer); + + bytes32 _offerHash = hashRentalOffer(_offer); + + nonceDeadline[msg.sender][_offer.nonce] = _offer.deadline; + isCreated[_offerHash] = true; + for (uint256 i = 0; i < _offer.roles.length; i++) { + require( + roleDeadline[_offer.roles[i]][_offer.tokenAddress][_offer.tokenId] < block.timestamp, + 'OriumNftMarketplace: role still has an active offer' + ); + roleDeadline[_offer.roles[i]][_offer.tokenAddress][_offer.tokenId] = _offer.deadline; + } + + emit RentalOfferCreated( + _offer.nonce, + _offer.tokenAddress, + _offer.tokenId, + _offer.lender, + _offer.borrower, + _offer.feeTokenAddress, + _offer.feeAmountPerSecond, + _offer.deadline, + _offer.minDuration, + _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 pure returns (bytes32) { + return keccak256(abi.encode(_offer)); + } + + /** ============================ Core Functions ================================== **/ + + /** ######### Setters ########### **/ + + /** + * @notice Sets the roles registry. + * @dev Only owner can set the roles registry. + */ + function pause() external onlyOwner { + _pause(); + } + + /** + * @notice Unpauses the contract. + * @dev Only owner can unpause the contract. + */ + function unpause() external onlyOwner { + _unpause(); + } + + /** ######### Internals ########### **/ + + /** + * @dev Validates the create rental offer. + * @param _offer The rental offer struct. + */ + function _validateCreateRentalOffer(RentalOffer calldata _offer) internal view { + require( + IOriumMarketplaceRoyalties(oriumMarketplaceRoyalties).isTrustedFeeTokenAddressForToken( + _offer.tokenAddress, + _offer.feeTokenAddress + ), + 'OriumSftMarketplace: tokenAddress is not trusted' + ); + require( + _offer.deadline <= block.timestamp + IOriumMarketplaceRoyalties(oriumMarketplaceRoyalties).maxDuration() && + _offer.deadline > block.timestamp, + 'OriumNftMarketplace: Invalid deadline' + ); + require(nonceDeadline[_offer.lender][_offer.nonce] == 0, 'OriumNftMarketplace: nonce already used'); + + require(_offer.nonce != 0, 'OriumNftMarketplace: Nonce cannot be 0'); + require(msg.sender == _offer.lender, 'OriumNftMarketplace: Sender and Lender mismatch'); + require(_offer.roles.length > 0, 'OriumNftMarketplace: roles should not be empty'); + require( + _offer.roles.length == _offer.rolesData.length, + 'OriumNftMarketplace: roles and rolesData should have the same length' + ); + require( + _offer.borrower != address(0) || _offer.feeAmountPerSecond > 0, + 'OriumNftMarketplace: feeAmountPerSecond should be greater than 0' + ); + require(_offer.minDuration <= _offer.deadline - block.timestamp, 'OriumNftMarketplace: minDuration is invalid'); + } +} diff --git a/hardhat.config.ts b/hardhat.config.ts index a3cfbf8..dcf87ed 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -61,8 +61,8 @@ const BASE_CONFIG = { networks: { hardhat: { forking: { - url: MUMBAI_PROVIDER_URL, - blockNumber: 40877850, + url: POLYGON_PROVIDER_URL, + blockNumber: 55899876, }, }, }, @@ -87,8 +87,8 @@ const PROD_CONFIG = { networks: { hardhat: { forking: { - url: MUMBAI_PROVIDER_URL, - blockNumber: 40877850, + url: POLYGON_PROVIDER_URL, + blockNumber: 55899876, }, }, mumbai: { diff --git a/test/OriumNftMarketplace.test.ts b/test/OriumNftMarketplace.test.ts new file mode 100644 index 0000000..7491952 --- /dev/null +++ b/test/OriumNftMarketplace.test.ts @@ -0,0 +1,254 @@ +import { ethers } from 'hardhat' +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers' +import { expect } from 'chai' +import { toWei } from '../utils/bignumber' +import { RentalOffer } from '../utils/types' +import { AddressZero, EMPTY_BYTES, ONE_DAY, THREE_MONTHS } from '../utils/constants' +import { randomBytes } from 'crypto' +import { USER_ROLE } from '../utils/roles' +import { IERC7432, MockERC20, MockERC721, OriumMarketplaceRoyalties, OriumNftMarketplace } from '../typechain-types' +import { deployNftMarketplaceContracts } from './fixtures/OriumNftMarketplaceFixture' + +describe('OriumNftMarketplace', () => { + let marketplace: OriumNftMarketplace + let marketplaceRoyalties: OriumMarketplaceRoyalties + let rolesRegistry: IERC7432 + let mockERC721: MockERC721 + let mockERC20: MockERC20 + + // 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 + let deployer: Awaited> + let operator: Awaited> + let notOperator: Awaited> + let creator: Awaited> + let lender: Awaited> + + // Values to be used across tests + const maxDeadline = THREE_MONTHS + const tokenId = 1 + + 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, lender] = 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, marketplaceRoyalties, rolesRegistry, mockERC721, mockERC20] = await loadFixture(deployNftMarketplaceContracts) + }) + + describe('Main Functions', async () => { + describe('Rental Functions', async () => { + beforeEach(async () => { + await mockERC721.mint(lender.address, tokenId) + await rolesRegistry + .connect(lender) + .setRoleApprovalForAll(await mockERC721.getAddress(), await marketplace.getAddress(), true) + await marketplaceRoyalties + .connect(operator) + .setTrustedFeeTokenForToken([await mockERC721.getAddress()], [await mockERC20.getAddress()], [true]) + await marketplaceRoyalties + .connect(operator) + .setRolesRegistry(await mockERC721.getAddress(), await rolesRegistry.getAddress()) + }) + + describe('Rental Offers', async () => { + let rentalOffer: RentalOffer + + beforeEach(async () => { + const blockTimestamp = Number((await ethers.provider.getBlock('latest'))?.timestamp) + + rentalOffer = { + nonce: `0x${randomBytes(32).toString('hex')}`, + lender: lender.address, + borrower: AddressZero, + tokenAddress: await mockERC721.getAddress(), + tokenId, + feeTokenAddress: await mockERC20.getAddress(), + feeAmountPerSecond: toWei('0.01'), + deadline: blockTimestamp + ONE_DAY, + minDuration: 0, + roles: [USER_ROLE], + rolesData: [EMPTY_BYTES], + } + }) + describe('When Rental Offer is not created', async () => { + describe('Create Rental Offer', async () => { + it('Should create a rental offer', async () => { + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)) + .to.emit(marketplace, 'RentalOfferCreated') + .withArgs( + rentalOffer.nonce, + rentalOffer.tokenAddress, + rentalOffer.tokenId, + rentalOffer.lender, + rentalOffer.borrower, + rentalOffer.feeTokenAddress, + rentalOffer.feeAmountPerSecond, + rentalOffer.deadline, + rentalOffer.minDuration, + rentalOffer.roles, + rentalOffer.rolesData, + ) + }) + it('Should create a rental offer with feeAmountPersSecond equal to 0. but with a specific borrower', async () => { + rentalOffer.feeAmountPerSecond = BigInt(0) + rentalOffer.borrower = creator.address + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)) + .to.emit(marketplace, 'RentalOfferCreated') + .withArgs( + rentalOffer.nonce, + rentalOffer.tokenAddress, + rentalOffer.tokenId, + rentalOffer.lender, + rentalOffer.borrower, + rentalOffer.feeTokenAddress, + rentalOffer.feeAmountPerSecond, + rentalOffer.deadline, + rentalOffer.minDuration, + rentalOffer.roles, + rentalOffer.rolesData, + ) + }) + it('Should create more than one rental offer for the same role, if the previous deadline is already expired', async () => { + await marketplace.connect(lender).createRentalOffer(rentalOffer) + await time.increase(ONE_DAY) + rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` + rentalOffer.deadline = Number((await ethers.provider.getBlock('latest'))?.timestamp) + ONE_DAY + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)) + .to.emit(marketplace, 'RentalOfferCreated') + .withArgs( + rentalOffer.nonce, + rentalOffer.tokenAddress, + rentalOffer.tokenId, + rentalOffer.lender, + rentalOffer.borrower, + rentalOffer.feeTokenAddress, + rentalOffer.feeAmountPerSecond, + rentalOffer.deadline, + rentalOffer.minDuration, + 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( + 'OriumNftMarketplace: 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( + 'OriumNftMarketplace: 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( + 'OriumNftMarketplace: 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( + 'OriumNftMarketplace: Invalid deadline', + ) + }) + it("Should NOT create a rental offer if deadline is less than block's timestamp", async () => { + rentalOffer.deadline = Number((await ethers.provider.getBlock('latest'))?.timestamp) - 1 + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumNftMarketplace: Invalid deadline', + ) + }) + it('Should NOT create the same rental offer twice', async () => { + await marketplace.connect(lender).createRentalOffer(rentalOffer) + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumNftMarketplace: nonce already used', + ) + }) + it('Should NOT create a rental offer if roles or rolesData are empty', async () => { + rentalOffer.roles = [] + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumNftMarketplace: roles should not be empty', + ) + }) + it('Should NOT create a rental offer if nonce is zero', async () => { + rentalOffer.nonce = '0' + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumNftMarketplace: Nonce cannot be 0', + ) + }) + it('Should NOT create a rental offer if feeAmountPerSecond is zero', async () => { + rentalOffer.feeAmountPerSecond = BigInt(0) + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumNftMarketplace: feeAmountPerSecond should be greater than 0', + ) + }) + it('Should NOT create a rental offer if tokenAddress is not trusted', async () => { + await marketplaceRoyalties + .connect(operator) + .setTrustedFeeTokenForToken([await mockERC721.getAddress()], [await mockERC20.getAddress()], [false]) + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumSftMarketplace: tokenAddress is not trusted', + ) + }) + it('Should NOT create a rental offer if deadline is less than minDuration', async () => { + rentalOffer.minDuration = ONE_DAY * 2 + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumNftMarketplace: minDuration is invalid', + ) + }) + it('Should NOT create more than one rental offer for the same role', async () => { + await marketplace.connect(lender).createRentalOffer(rentalOffer) + rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'OriumNftMarketplace: role still has an active offer', + ) + }) + }) + }) + }) + }) + 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.ZeroAddress)).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 + }) + + 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', + ) + }) + }) + 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', + ) + }) + }) + }) + }) + }) +}) diff --git a/test/fixtures/OriumNftMarketplaceFixture.ts b/test/fixtures/OriumNftMarketplaceFixture.ts new file mode 100644 index 0000000..ad55468 --- /dev/null +++ b/test/fixtures/OriumNftMarketplaceFixture.ts @@ -0,0 +1,48 @@ +import { ethers, upgrades } from 'hardhat' +import { AddressZero, RolesRegistryAddress, THREE_MONTHS } from '../../utils/constants' +import { IERC7432, MockERC20, MockERC721, OriumMarketplaceRoyalties, OriumNftMarketplace } from '../../typechain-types' +/** + * @dev deployer, operator needs to be the first accounts in the hardhat ethers.getSigners() + * list respectively. This should be considered to use this fixture in tests + * @returns [marketplace, marketplaceRoyalties, rolesRegistry, mockERC721, mockERC20] + */ +export async function deployNftMarketplaceContracts() { + const [, operator] = await ethers.getSigners() + + const rolesRegistry: IERC7432 = await ethers.getContractAt('IERC7432', RolesRegistryAddress) + + const MarketplaceRoyaltiesFactory = await ethers.getContractFactory('OriumMarketplaceRoyalties') + const marketplaceRoyaltiesProxy = await upgrades.deployProxy(MarketplaceRoyaltiesFactory, [ + operator.address, + await rolesRegistry.getAddress(), + AddressZero, + THREE_MONTHS, + ]) + await marketplaceRoyaltiesProxy.waitForDeployment() + const marketplaceRoyalties: OriumMarketplaceRoyalties = await ethers.getContractAt( + 'OriumMarketplaceRoyalties', + await marketplaceRoyaltiesProxy.getAddress(), + ) + + const MarketplaceFactory = await ethers.getContractFactory('OriumNftMarketplace') + const marketplaceProxy = await upgrades.deployProxy(MarketplaceFactory, [ + operator.address, + await marketplaceRoyalties.getAddress(), + ]) + await marketplaceProxy.waitForDeployment() + + const marketplace: OriumNftMarketplace = await ethers.getContractAt( + 'OriumNftMarketplace', + await marketplaceProxy.getAddress(), + ) + + const MockERC721Factory = await ethers.getContractFactory('MockERC721') + const mockERC721: MockERC721 = await MockERC721Factory.deploy() + await mockERC721.waitForDeployment() + + const MockERC20Factory = await ethers.getContractFactory('MockERC20') + const mockERC20: MockERC20 = await MockERC20Factory.deploy() + await mockERC20.waitForDeployment() + + return [marketplace, marketplaceRoyalties, rolesRegistry, mockERC721, mockERC20] as const +} diff --git a/utils/types.ts b/utils/types.ts index 8b0c98a..aea4ff9 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -18,6 +18,7 @@ export interface RentalOffer { feeTokenAddress: string feeAmountPerSecond: bigint deadline: number + minDuration: number roles: string[] rolesData: string[] } From 6f13825122237f4de42791488d2aca3581742676 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Tue, 16 Apr 2024 16:34:54 -0300 Subject: [PATCH 02/11] ON-812: trigger CI --- hardhat.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hardhat.config.ts b/hardhat.config.ts index dcf87ed..67e247d 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -62,7 +62,7 @@ const BASE_CONFIG = { hardhat: { forking: { url: POLYGON_PROVIDER_URL, - blockNumber: 55899876, + blockNumber: 55899875, }, }, }, @@ -88,7 +88,7 @@ const PROD_CONFIG = { hardhat: { forking: { url: POLYGON_PROVIDER_URL, - blockNumber: 55899876, + blockNumber: 55899875, }, }, mumbai: { From db7eb5107d4e9785d939b2500f43942df6d53cb0 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Tue, 16 Apr 2024 18:10:02 -0300 Subject: [PATCH 03/11] ON-812: update CI --- .github/workflows/all.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/all.yml b/.github/workflows/all.yml index ae8a61b..8931aeb 100644 --- a/.github/workflows/all.yml +++ b/.github/workflows/all.yml @@ -26,4 +26,4 @@ jobs: - name: Test Coverage run: npm run coverage env: - MUMBAI_PROVIDER_URL: ${{ secrets.MUMBAI_PROVIDER_URL }} + POLYGON_PROVIDER_URL: ${{ secrets.POLYGON_PROVIDER_URL }} From 8849dc937ecf7ed6a813ec4da0c2c1af5d22b2e9 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Thu, 18 Apr 2024 16:50:38 -0300 Subject: [PATCH 04/11] ON-812: PR fixes --- contracts/OriumNftMarketplace.sol | 9 ++++-- .../interfaces/IERC7432VaultExtension.sol | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 contracts/interfaces/IERC7432VaultExtension.sol diff --git a/contracts/OriumNftMarketplace.sol b/contracts/OriumNftMarketplace.sol index a602172..c909bfc 100644 --- a/contracts/OriumNftMarketplace.sol +++ b/contracts/OriumNftMarketplace.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.9; import { IERC721 } from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; +import { IERC7432VaultExtension } from './interfaces/IERC7432VaultExtension.sol'; import { IOriumMarketplaceRoyalties } from './interfaces/IOriumMarketplaceRoyalties.sol'; import { OwnableUpgradeable } from '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; import { Initializable } from '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol'; @@ -82,8 +83,12 @@ contract OriumNftMarketplace is Initializable, OwnableUpgradeable, PausableUpgra * @param _tokenId The id of the token. */ modifier onlyTokenOwner(address _tokenAddress, uint256 _tokenId) { + address _rolesRegistry = IOriumMarketplaceRoyalties(oriumMarketplaceRoyalties).nftRolesRegistryOf( + _tokenAddress + ); require( - msg.sender == IERC721(_tokenAddress).ownerOf(_tokenId), + msg.sender == IERC721(_tokenAddress).ownerOf(_tokenId) || + msg.sender == IERC7432VaultExtension(_rolesRegistry).ownerOf(_tokenAddress, _tokenId), 'OriumNftMarketplace: only token owner can call this function' ); _; @@ -96,7 +101,7 @@ contract OriumNftMarketplace is Initializable, OwnableUpgradeable, PausableUpgra * @param _owner The owner of the protocol. * @param _oriumMarketplaceRoyalties The address of the OriumMarketplaceRoyalties contract. */ - function initialize(address _owner, address _oriumMarketplaceRoyalties) public initializer { + function initialize(address _owner, address _oriumMarketplaceRoyalties) external initializer { __Pausable_init(); __Ownable_init(); diff --git a/contracts/interfaces/IERC7432VaultExtension.sol b/contracts/interfaces/IERC7432VaultExtension.sol new file mode 100644 index 0000000..1e56014 --- /dev/null +++ b/contracts/interfaces/IERC7432VaultExtension.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.9; + +/// @title ERC-7432 Vault Extension +/// @dev See https://eips.ethereum.org/EIPS/eip-7432 +/// Note: the ERC-165 identifier for this interface is 0xecd7217f. +interface IERC7432VaultExtension { + /** Events **/ + + /// @notice Emitted when an NFT is withdrawn. + /// @param _owner The original owner of the NFT. + /// @param _tokenAddress The token address. + /// @param _tokenId The token identifier. + event Withdraw(address indexed _owner, address indexed _tokenAddress, uint256 indexed _tokenId); + + /** External Functions **/ + + /// @notice Withdraw NFT back to original owner. + /// @dev Reverts if sender is not approved or the original owner. + /// @param _tokenAddress The token address. + /// @param _tokenId The token identifier. + function withdraw(address _tokenAddress, uint256 _tokenId) external; + + /** View Functions **/ + + /// @notice Retrieves the owner of a deposited NFT. + /// @param _tokenAddress The token address. + /// @param _tokenId The token identifier. + /// @return owner_ The owner of the token. + function ownerOf(address _tokenAddress, uint256 _tokenId) external view returns (address owner_); +} From 56478cfa022a3f03264041a6925a8ee9720c3eac Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Thu, 18 Apr 2024 17:45:21 -0300 Subject: [PATCH 05/11] ON-812: Add LibOriumNftMarketplace --- contracts/OriumMarketplace.sol | 823 ------------------ contracts/OriumNftMarketplace.sol | 94 +- contracts/interfaces/IERC7432.sol | 163 ++-- .../libraries/LibOriumNftMarketplace.sol | 77 ++ contracts/mocks/NftRolesRegistryVault.sol | 229 +++++ test/OriumMarketplace.test.ts | 803 ----------------- test/OriumNftMarketplace.test.ts | 2 +- test/fixtures/OriumMarketplaceFixture.ts | 36 - test/fixtures/OriumNftMarketplaceFixture.ts | 34 +- 9 files changed, 399 insertions(+), 1862 deletions(-) delete mode 100644 contracts/OriumMarketplace.sol create mode 100644 contracts/libraries/LibOriumNftMarketplace.sol create mode 100644 contracts/mocks/NftRolesRegistryVault.sol delete mode 100644 test/OriumMarketplace.test.ts delete mode 100644 test/fixtures/OriumMarketplaceFixture.ts diff --git a/contracts/OriumMarketplace.sol b/contracts/OriumMarketplace.sol deleted file mode 100644 index ed89ad8..0000000 --- a/contracts/OriumMarketplace.sol +++ /dev/null @@ -1,823 +0,0 @@ -// SPDX-License-Identifier: CC0-1.0 - -pragma solidity 0.8.9; - -import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { IERC7432, RoleAssignment } from "./interfaces/IERC7432.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"; - -/** - * @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 { - /** ######### 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 defaultRolesRegistry; - - /// @dev tokenAddress => rolesRegistry - mapping(address => address) public tokenRolesRegistry; - - /// @dev deadline is set in seconds - uint256 public maxDeadline; - - /// @dev tokenAddress => feePercentageInWei - mapping(address => FeeInfo) public feeInfo; - - /// @dev tokenAddress => royaltyInfo - mapping(address => RoyaltyInfo) public royaltyInfo; - - /// @dev hashedOffer => bool - mapping(bytes32 => bool) public isCreated; - - /// @dev lender => nonce => deadline - mapping(address => mapping(uint256 => uint64)) public nonceDeadline; - - /// @dev hashedOffer => Rental - mapping(bytes32 => Rental) public rentals; - - /** ######### Structs ########### **/ - - struct Rental { - address borrower; - uint64 expirationDate; - } - - /// @dev Royalty info. Used to charge fees for the creator. - struct RoyaltyInfo { - address creator; - uint256 royaltyPercentageInWei; - address treasury; - } - - /// @dev Marketplace fee info. - struct FeeInfo { - uint256 feePercentageInWei; - 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; - } - - /// @dev Direct rental info. - struct DirectRental { - address tokenAddress; - uint256 tokenId; - address lender; - address borrower; - uint64 duration; - 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 tokenAddress The address of the contract of the NFT to rent - * @param tokenId The tokenId of the NFT to rent - * @param lender The address of the user lending the NFT - * @param borrower The address of the user renting the NFT - * @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 tokenAddress, - uint256 indexed tokenId, - address lender, - address borrower, - address feeTokenAddress, - uint256 feeAmountPerSecond, - uint256 deadline, - bytes32[] roles, - bytes[] rolesData - ); - - /** - * @param nonce The nonce of the rental offer - * @param tokenAddress The address of the contract of the NFT rented - * @param tokenId The tokenId of the rented NFT - * @param lender The address of the lender - * @param borrower The address of the borrower - * @param expirationDate The expiration date of the rental - */ - event RentalStarted( - uint256 indexed nonce, - address indexed tokenAddress, - uint256 indexed tokenId, - address lender, - address borrower, - uint64 expirationDate - ); - - /** - * @param nonce The nonce of the rental offer - * @param lender The address of the user lending the NFT - */ - event RentalOfferCancelled(uint256 indexed nonce, address indexed lender); - - /** - * @param nonce The nonce of the rental offer - * @param tokenAddress The address of the contract of the NFT rented - * @param tokenId The tokenId of the rented NFT - * @param lender The address of the lender - * @param borrower The address of the borrower - */ - event RentalEnded( - uint256 indexed nonce, - address indexed tokenAddress, - uint256 indexed tokenId, - address lender, - address borrower - ); - - /** - * @param tokenAddress The NFT address. - * @param rolesRegistry The address of the roles registry. - */ - event RolesRegistrySet(address indexed tokenAddress, address indexed rolesRegistry); - - /** - * @param directRentalHash The hash of the direct rental - * @param tokenAddress The address of the contract of the NFT rented - * @param tokenId The tokenId of the rented NFT - * @param lender The address of the lender - * @param borrower The address of the borrower - * @param duration The duration of the rental - * @param roles The array of roles to be assigned to the borrower - * @param rolesData The array of data for each role - */ - event DirectRentalStarted( - bytes32 indexed directRentalHash, - address indexed tokenAddress, - uint256 indexed tokenId, - address lender, - address borrower, - uint256 duration, - bytes32[] roles, - bytes[] rolesData - ); - - /** - * @param directRentalHash The hash of the direct rental - * @param lender The address of the user lending the NFT - */ - event DirectRentalEnded(bytes32 indexed directRentalHash, address indexed lender); - - /** ######### 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) { - require( - msg.sender == IERC721(_tokenAddress).ownerOf(_tokenId), - "OriumMarketplace: only token owner can call this function" - ); - _; - } - - /** ######### Initializer ########### **/ - /** - * @notice Initializes the contract. - * @dev The owner of the contract will be the owner of the protocol. - * @param _owner The owner of the protocol. - * @param _defaultRolesRegistry The address of the roles registry. - * @param _maxDeadline The maximum deadline. - */ - function initialize(address _owner, address _defaultRolesRegistry, uint256 _maxDeadline) public initializer { - __Pausable_init(); - __Ownable_init(); - - defaultRolesRegistry = _defaultRolesRegistry; - maxDeadline = _maxDeadline; - - transferOwnership(_owner); - } - - /** ============================ 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) { - _validateCreateRentalOffer(_offer); - - bytes32 _offerHash = hashRentalOffer(_offer); - - nonceDeadline[msg.sender][_offer.nonce] = _offer.deadline; - isCreated[_offerHash] = true; - - emit RentalOfferCreated( - _offer.nonce, - _offer.tokenAddress, - _offer.tokenId, - _offer.lender, - _offer.borrower, - _offer.feeTokenAddress, - _offer.feeAmountPerSecond, - _offer.deadline, - _offer.roles, - _offer.rolesData - ); - } - - /** - * @dev Validates the create rental offer. - * @param _offer The rental offer struct. - */ - function _validateCreateRentalOffer(RentalOffer calldata _offer) internal view { - require(_offer.nonce != 0, "OriumMarketplace: Nonce cannot be 0"); - require(msg.sender == _offer.lender, "OriumMarketplace: Sender and Lender mismatch"); - require(_offer.roles.length > 0, "OriumMarketplace: roles should not be empty"); - 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"); - } - - function cancelRentalOffer(uint256 nonce) external { - require(nonceDeadline[msg.sender][nonce] > block.timestamp, "OriumMarketplace: Nonce expired or not used yet"); - - nonceDeadline[msg.sender][nonce] = uint64(block.timestamp); - emit RentalOfferCancelled(nonce, msg.sender); - } - - /** - * @notice Accepts a rental offer. - * @dev The borrower can be address(0) to allow anyone to rent the NFT. - * @param _offer The rental offer struct. It should be the same as the one used to create the offer. - * @param _duration The duration of the rental. - */ - function acceptRentalOffer(RentalOffer calldata _offer, uint64 _duration) external { - uint64 _expirationDate = uint64(block.timestamp + _duration); - - _validateAcceptRentalOffer(_offer, _expirationDate); - - _transferFees(_offer.tokenAddress, _offer.feeTokenAddress, _offer.feeAmountPerSecond, _duration, _offer.lender); - - _batchGrantRole( - _offer.roles, - _offer.rolesData, - _offer.tokenAddress, - _offer.tokenId, - _offer.lender, - msg.sender, - _expirationDate, - false - ); - - rentals[hashRentalOffer(_offer)] = Rental({ borrower: msg.sender, expirationDate: _expirationDate }); - - emit RentalStarted( - _offer.nonce, - _offer.tokenAddress, - _offer.tokenId, - _offer.lender, - msg.sender, - _expirationDate - ); - } - - /** - * @dev Validates the accept rental offer. - * @param _offer The rental offer struct. It should be the same as the one used to create the offer. - * @param _expirationDate The period of time the NFT will be rented. - */ - function _validateAcceptRentalOffer(RentalOffer calldata _offer, uint64 _expirationDate) internal view { - bytes32 _offerHash = hashRentalOffer(_offer); - require(rentals[_offerHash].expirationDate <= block.timestamp, "OriumMarketplace: Rental already started"); - require(isCreated[_offerHash], "OriumMarketplace: Offer not created"); - require( - address(0) == _offer.borrower || msg.sender == _offer.borrower, - "OriumMarketplace: Sender is not allowed to rent this NFT" - ); - require( - nonceDeadline[_offer.lender][_offer.nonce] > _expirationDate, - "OriumMarketplace: expiration date is greater than offer deadline" - ); - } - - /** - * @dev Transfers the fees to the marketplace, the creator and the lender. - * @param _feeTokenAddress The address of the ERC20 token for rental fees. - * @param _feeAmountPerSecond The amount of fee per second. - * @param _duration The duration of the rental. - * @param _lenderAddress The address of the lender. - */ - function _transferFees( - address _tokenAddress, - address _feeTokenAddress, - uint256 _feeAmountPerSecond, - uint64 _duration, - address _lenderAddress - ) internal { - uint256 _feeAmount = _feeAmountPerSecond * _duration; - if (_feeAmount == 0) return; - - uint256 _marketplaceFeeAmount = _getAmountFromPercentage(_feeAmount, marketplaceFeeOf(_tokenAddress)); - if (_marketplaceFeeAmount > 0) { - require( - IERC20(_feeTokenAddress).transferFrom(msg.sender, owner(), _marketplaceFeeAmount), - "OriumMarketplace: Transfer failed" - ); - } - - uint256 _royaltyAmount = _getAmountFromPercentage( - _feeAmount, - royaltyInfo[_tokenAddress].royaltyPercentageInWei - ); - if (_royaltyAmount > 0) { - require( - IERC20(_feeTokenAddress).transferFrom(msg.sender, royaltyInfo[_tokenAddress].treasury, _royaltyAmount), - "OriumMarketplace: Transfer failed" - ); - } - - uint256 _lenderAmount = _feeAmount - _royaltyAmount - _marketplaceFeeAmount; - require( - IERC20(_feeTokenAddress).transferFrom(msg.sender, _lenderAddress, _lenderAmount), - "OriumMarketplace: Transfer failed" - ); // TODO: Change to vesting contract address later - } - - /** - * @dev All values needs to be in wei. - * @param _amount The amount to calculate the percentage from. - * @param _percentage The percentage to calculate. - */ - function _getAmountFromPercentage(uint256 _amount, uint256 _percentage) internal pure returns (uint256) { - return (_amount * _percentage) / MAX_PERCENTAGE; - } - - /** - * @dev Grants the roles to the borrower. - * @param _roles The array of roles to be assigned to the borrower - * @param _rolesData The array of data for each role - * @param _tokenAddress The address of the contract of the NFT to rent - * @param _tokenId The tokenId of the NFT to rent - * @param _grantor The address of the user lending the NFT - * @param _grantee The address of the user renting the NFT - * @param _expirationDate The deadline until when the rental offer is valid - */ - function _batchGrantRole( - bytes32[] memory _roles, - bytes[] memory _rolesData, - address _tokenAddress, - uint256 _tokenId, - address _grantor, - address _grantee, - uint64 _expirationDate, - bool _revocable - ) internal { - address _rolesRegistry = rolesRegistryOf(_tokenAddress); - for (uint256 i = 0; i < _roles.length; i++) { - _grantUniqueRoleChecked( // Needed to avoid stack too deep error - _roles[i], - _tokenAddress, - _tokenId, - _grantor, - _grantee, - _expirationDate, - _rolesData[i], - _rolesRegistry, - _revocable - ); - } - } - - /** - * @dev Grants the role to the borrower. - * @param _role The role to be granted - * @param _tokenAddress The address of the contract of the NFT to rent - * @param _tokenId The tokenId of the NFT to rent - * @param _grantor The address of the user lending the NFT - * @param _grantee The address of the user renting the NFT - * @param _expirationDate The deadline until when the rental offer is valid - * @param _data The data for the role - */ - function _grantUniqueRoleChecked( - bytes32 _role, - address _tokenAddress, - uint256 _tokenId, - address _grantor, - address _grantee, - uint64 _expirationDate, - bytes memory _data, - address _rolesRegistry, - bool _revocable - ) internal { - RoleAssignment memory _roleAssignment = RoleAssignment({ - role: _role, - tokenAddress: _tokenAddress, - tokenId: _tokenId, - grantor: _grantor, - grantee: _grantee, - expirationDate: _expirationDate, - data: _data - }); - if (_revocable) { - IERC7432(_rolesRegistry).grantRevocableRoleFrom(_roleAssignment); - } else { - IERC7432(_rolesRegistry).grantRoleFrom(_roleAssignment); - } - } - - /** - * @notice Ends the rental. - * @dev Can only be called by the borrower. - * @dev Borrower needs to approve marketplace to revoke the roles. - * @param _offer The rental offer struct. It should be the same as the one used to create the offer. - */ - function endRental(RentalOffer memory _offer) external { - bytes32 _offerHash = hashRentalOffer(_offer); - - _validateEndRental(_offer, _offerHash); - - _batchRevokeRole( - _offer.roles, - _offer.tokenAddress, - _offer.tokenId, - _offer.lender, - rentals[_offerHash].borrower - ); - - rentals[_offerHash].expirationDate = uint64(block.timestamp); - - emit RentalEnded( - _offer.nonce, - _offer.tokenAddress, - _offer.tokenId, - _offer.lender, - rentals[_offerHash].borrower - ); - } - - /** - * @dev Validates the end rental. - * @param _offer The rental offer struct. It should be the same as the one used to create the offer. - * @param _offerHash The hash of the rental offer struct. - */ - function _validateEndRental(RentalOffer memory _offer, bytes32 _offerHash) internal view { - require(isCreated[_offerHash], "OriumMarketplace: Offer not created"); - require(msg.sender == rentals[_offerHash].borrower, "OriumMarketplace: Only borrower can end a rental"); - require(nonceDeadline[_offer.lender][_offer.nonce] > block.timestamp, "OriumMarketplace: Rental Offer expired"); - require(rentals[_offerHash].expirationDate > block.timestamp, "OriumMarketplace: Rental ended"); - } - - /** - * @dev Revokes the roles from the borrower. - * @param _roles The array of roles to be revoked from the borrower - * @param _tokenAddress The address of the contract of the NFT to rent - * @param _tokenId The tokenId of the NFT to rent - * @param _grantor The address of the user lending the NFT - * @param _grantee The address of the user renting the NFT - */ - function _batchRevokeRole( - bytes32[] memory _roles, - address _tokenAddress, - uint256 _tokenId, - address _grantor, - address _grantee - ) internal { - address _rolesRegistry = rolesRegistryOf(_tokenAddress); - for (uint256 i = 0; i < _roles.length; i++) { - IERC7432(_rolesRegistry).revokeRoleFrom(_roles[i], _tokenAddress, _tokenId, _grantor, _grantee); - } - } - - /** - * @notice Creates a direct rental. - * @dev The lender needs to approve marketplace to grant the roles. - * @param _directRental The direct rental struct. - */ - function createDirectRental( - DirectRental memory _directRental - ) external onlyTokenOwner(_directRental.tokenAddress, _directRental.tokenId) { - _validateCreateDirectRental(_directRental); - - bytes32 _hashedDirectRental = hashDirectRental(_directRental); - uint64 _expirationDate = uint64(block.timestamp + _directRental.duration); - isCreated[_hashedDirectRental] = true; - rentals[_hashedDirectRental] = Rental({ borrower: _directRental.borrower, expirationDate: _expirationDate }); - - _batchGrantRole( - _directRental.roles, - _directRental.rolesData, - _directRental.tokenAddress, - _directRental.tokenId, - _directRental.lender, - _directRental.borrower, - _expirationDate, - true - ); - - emit DirectRentalStarted( - _hashedDirectRental, - _directRental.tokenAddress, - _directRental.tokenId, - _directRental.lender, - _directRental.borrower, - _directRental.duration, - _directRental.roles, - _directRental.rolesData - ); - } - - /** - * @dev Validates the create direct rental. - * @param _directRental The direct rental struct. - */ - function _validateCreateDirectRental(DirectRental memory _directRental) internal view { - require(_directRental.duration <= maxDeadline, "OriumMarketplace: Duration is greater than max deadline"); - require(msg.sender == _directRental.lender, "OriumMarketplace: Sender and Lender mismatch"); - require(_directRental.roles.length > 0, "OriumMarketplace: roles should not be empty"); - require( - _directRental.roles.length == _directRental.rolesData.length, - "OriumMarketplace: roles and rolesData should have the same length" - ); - } - - /** - * @notice Cancels a direct rental. - * @dev The lender needs to approve marketplace to revoke the roles. - * @param _directRental The direct rental struct. - */ - function cancelDirectRental(DirectRental memory _directRental) external { - bytes32 _hashedDirectRental = hashDirectRental(_directRental); - - _validateCancelDirectRental(_directRental, _hashedDirectRental); - - rentals[_hashedDirectRental].expirationDate = uint64(block.timestamp); - - _batchRevokeRole( - _directRental.roles, - _directRental.tokenAddress, - _directRental.tokenId, - _directRental.lender, - _directRental.borrower - ); - - emit DirectRentalEnded(_hashedDirectRental, _directRental.lender); - } - - function _validateCancelDirectRental(DirectRental memory _directRental, bytes32 _hashedDirectRental) internal view { - require(isCreated[_hashedDirectRental], "OriumMarketplace: Direct rental not created"); - require( - rentals[_hashedDirectRental].expirationDate > block.timestamp, - "OriumMarketplace: Direct rental expired" - ); - require( - msg.sender == _directRental.lender || msg.sender == _directRental.borrower, - "OriumMarketplace: Sender and Lender/Borrower mismatch" - ); - } - - /** ######### Getters ########### **/ - - /** - * @notice Gets the rental offer hash. - * @param _offer The rental offer struct to be hashed. - */ - function hashRentalOffer(RentalOffer memory _offer) public pure returns (bytes32) { - return - keccak256( - abi.encode( - _offer.lender, - _offer.borrower, - _offer.tokenAddress, - _offer.tokenId, - _offer.feeTokenAddress, - _offer.feeAmountPerSecond, - _offer.nonce, - _offer.deadline, - _offer.roles, - _offer.rolesData - ) - ); - } - - /** - * @notice Gets the direct rental hash. - * @param _directRental The direct rental struct to be hashed. - */ - function hashDirectRental(DirectRental memory _directRental) public pure returns (bytes32) { - return - keccak256( - abi.encode( - _directRental.tokenAddress, - _directRental.tokenId, - _directRental.lender, - _directRental.borrower, - _directRental.duration, - _directRental.roles, - _directRental.rolesData - ) - ); - } - - /** ============================ Core Functions ================================== **/ - - /** ######### Setters ########### **/ - - /** - * @notice Sets the roles registry. - * @dev Only owner can set the roles registry. - */ - function pause() external onlyOwner { - _pause(); - } - - /** - * @notice Unpauses the contract. - * @dev Only owner can unpause the contract. - */ - function unpause() external onlyOwner { - _unpause(); - } - - /** - * @notice Sets the marketplace fee for a collection. - * @dev If no fee is set, the default fee will be used. - * @param _tokenAddress The NFT address. - * @param _feePercentageInWei The fee percentage in wei. - * @param _isCustomFee If the fee is custom or not. - */ - function setMarketplaceFeeForCollection( - address _tokenAddress, - uint256 _feePercentageInWei, - bool _isCustomFee - ) external onlyOwner { - uint256 _royaltyPercentage = royaltyInfo[_tokenAddress].royaltyPercentageInWei; - require( - _royaltyPercentage + _feePercentageInWei < MAX_PERCENTAGE, - "OriumMarketplace: Royalty percentage + marketplace fee cannot be greater than 100%" - ); - - feeInfo[_tokenAddress] = FeeInfo({ feePercentageInWei: _feePercentageInWei, isCustomFee: _isCustomFee }); - - emit MarketplaceFeeSet(_tokenAddress, _feePercentageInWei, _isCustomFee); - } - - /** - * @notice Sets the royalty info. - * @dev Only owner can associate a collection with a 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)); - } - - /** - * @notice Sets the royalty info. - * @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 { - require( - msg.sender == royaltyInfo[_tokenAddress].creator, - "OriumMarketplace: Only creator can set royalty info" - ); - - _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, - uint256 _royaltyPercentageInWei, - address _treasury - ) internal { - require( - _royaltyPercentageInWei + marketplaceFeeOf(_tokenAddress) < MAX_PERCENTAGE, - "OriumMarketplace: Royalty percentage + marketplace fee cannot be greater than 100%" - ); - - royaltyInfo[_tokenAddress] = RoyaltyInfo({ - creator: _creator, - royaltyPercentageInWei: _royaltyPercentageInWei, - treasury: _treasury - }); - - emit CreatorRoyaltySet(_tokenAddress, _creator, _royaltyPercentageInWei, _treasury); - } - - /** - * @notice Sets the maximum deadline. - * @dev Only owner can set the maximum deadline. - * @param _maxDeadline The maximum deadline. - */ - function setMaxDeadline(uint256 _maxDeadline) external onlyOwner { - require(_maxDeadline > 0, "OriumMarketplace: Max deadline should be greater than 0"); - maxDeadline = _maxDeadline; - } - - /** - * @notice Sets the roles registry for a collection. - * @dev Only owner can set the roles registry for a collection. - * @param _tokenAddress The NFT address. - * @param _rolesRegistry The roles registry address. - */ - function setRolesRegistry(address _tokenAddress, address _rolesRegistry) external onlyOwner { - tokenRolesRegistry[_tokenAddress] = _rolesRegistry; - emit RolesRegistrySet(_tokenAddress, _rolesRegistry); - } - - /** - * @notice Sets the default roles registry. - * @dev Only owner can set the default roles registry. - * @param _rolesRegistry The roles registry address. - */ - function setDefaultRolesRegistry(address _rolesRegistry) external onlyOwner { - defaultRolesRegistry = _rolesRegistry; - } - - /** ######### 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; - } - - /** - * @notice Gets the roles registry for a collection. - * @dev If no custom roles registry is set, the default roles registry will be used. - * @param _tokenAddress The NFT address. - */ - function rolesRegistryOf(address _tokenAddress) public view returns (address) { - return - tokenRolesRegistry[_tokenAddress] == address(0) ? defaultRolesRegistry : tokenRolesRegistry[_tokenAddress]; - } -} diff --git a/contracts/OriumNftMarketplace.sol b/contracts/OriumNftMarketplace.sol index c909bfc..da2a73c 100644 --- a/contracts/OriumNftMarketplace.sol +++ b/contracts/OriumNftMarketplace.sol @@ -8,6 +8,7 @@ import { IOriumMarketplaceRoyalties } from './interfaces/IOriumMarketplaceRoyalt 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 { LibOriumNftMarketplace, RentalOffer } from './libraries/LibOriumNftMarketplace.sol'; /** * @title Orium NFT Marketplace - Marketplace for renting NFTs @@ -29,23 +30,6 @@ contract OriumNftMarketplace is Initializable, OwnableUpgradeable, PausableUpgra /// @dev role => tokenAddress => tokenId => deadline mapping(bytes32 => mapping(address => mapping(uint256 => uint64))) public roleDeadline; - /** ######### Structs ########### **/ - - /// @dev Rental offer info. - struct RentalOffer { - address lender; - address borrower; - address tokenAddress; - uint256 tokenId; - address feeTokenAddress; - uint256 feeAmountPerSecond; - uint256 nonce; - uint64 deadline; - uint64 minDuration; - bytes32[] roles; - bytes[] rolesData; - } - /** ######### Events ########### **/ /** @@ -74,26 +58,6 @@ contract OriumNftMarketplace is Initializable, OwnableUpgradeable, PausableUpgra 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) { - address _rolesRegistry = IOriumMarketplaceRoyalties(oriumMarketplaceRoyalties).nftRolesRegistryOf( - _tokenAddress - ); - require( - msg.sender == IERC721(_tokenAddress).ownerOf(_tokenId) || - msg.sender == IERC7432VaultExtension(_rolesRegistry).ownerOf(_tokenAddress, _tokenId), - 'OriumNftMarketplace: only token owner can call this function' - ); - _; - } - /** ######### Initializer ########### **/ /** * @notice Initializes the contract. @@ -120,13 +84,18 @@ contract OriumNftMarketplace is Initializable, OwnableUpgradeable, PausableUpgra */ function createRentalOffer( RentalOffer calldata _offer - ) external onlyTokenOwner(_offer.tokenAddress, _offer.tokenId) { - _validateCreateRentalOffer(_offer); + ) external { + LibOriumNftMarketplace.validateCreateRentalOfferParams( + oriumMarketplaceRoyalties, + _offer, + nonceDeadline[msg.sender][_offer.nonce] + ); - bytes32 _offerHash = hashRentalOffer(_offer); + bytes32 _offerHash = LibOriumNftMarketplace.hashRentalOffer(_offer); nonceDeadline[msg.sender][_offer.nonce] = _offer.deadline; isCreated[_offerHash] = true; + for (uint256 i = 0; i < _offer.roles.length; i++) { require( roleDeadline[_offer.roles[i]][_offer.tokenAddress][_offer.tokenId] < block.timestamp, @@ -150,16 +119,6 @@ contract OriumNftMarketplace is Initializable, OwnableUpgradeable, PausableUpgra ); } - /** ######### Getters ########### **/ - - /** - * @notice Gets the rental offer hash. - * @param _offer The rental offer struct to be hashed. - */ - function hashRentalOffer(RentalOffer memory _offer) public pure returns (bytes32) { - return keccak256(abi.encode(_offer)); - } - /** ============================ Core Functions ================================== **/ /** ######### Setters ########### **/ @@ -179,39 +138,4 @@ contract OriumNftMarketplace is Initializable, OwnableUpgradeable, PausableUpgra function unpause() external onlyOwner { _unpause(); } - - /** ######### Internals ########### **/ - - /** - * @dev Validates the create rental offer. - * @param _offer The rental offer struct. - */ - function _validateCreateRentalOffer(RentalOffer calldata _offer) internal view { - require( - IOriumMarketplaceRoyalties(oriumMarketplaceRoyalties).isTrustedFeeTokenAddressForToken( - _offer.tokenAddress, - _offer.feeTokenAddress - ), - 'OriumSftMarketplace: tokenAddress is not trusted' - ); - require( - _offer.deadline <= block.timestamp + IOriumMarketplaceRoyalties(oriumMarketplaceRoyalties).maxDuration() && - _offer.deadline > block.timestamp, - 'OriumNftMarketplace: Invalid deadline' - ); - require(nonceDeadline[_offer.lender][_offer.nonce] == 0, 'OriumNftMarketplace: nonce already used'); - - require(_offer.nonce != 0, 'OriumNftMarketplace: Nonce cannot be 0'); - require(msg.sender == _offer.lender, 'OriumNftMarketplace: Sender and Lender mismatch'); - require(_offer.roles.length > 0, 'OriumNftMarketplace: roles should not be empty'); - require( - _offer.roles.length == _offer.rolesData.length, - 'OriumNftMarketplace: roles and rolesData should have the same length' - ); - require( - _offer.borrower != address(0) || _offer.feeAmountPerSecond > 0, - 'OriumNftMarketplace: feeAmountPerSecond should be greater than 0' - ); - require(_offer.minDuration <= _offer.deadline - block.timestamp, 'OriumNftMarketplace: minDuration is invalid'); - } } diff --git a/contracts/interfaces/IERC7432.sol b/contracts/interfaces/IERC7432.sol index 887cb30..d0f103d 100644 --- a/contracts/interfaces/IERC7432.sol +++ b/contracts/interfaces/IERC7432.sol @@ -2,95 +2,69 @@ pragma solidity 0.8.9; -import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; - -struct RoleData { - uint64 expirationDate; - bool revocable; - bytes data; -} - -struct RoleAssignment { - bytes32 role; - address tokenAddress; - uint256 tokenId; - address grantor; - address grantee; - uint64 expirationDate; - bytes data; -} +import { IERC165 } from '@openzeppelin/contracts/utils/introspection/IERC165.sol'; /// @title ERC-7432 Non-Fungible Token Roles /// @dev See https://eips.ethereum.org/EIPS/eip-7432 -/// Note: the ERC-165 identifier for this interface is 0x04984ac8. +/// Note: the ERC-165 identifier for this interface is 0xfecc9ed3. interface IERC7432 is IERC165 { + struct Role { + bytes32 roleId; + address tokenAddress; + uint256 tokenId; + address recipient; + uint64 expirationDate; + bool revocable; + bytes data; + } + /** Events **/ /// @notice Emitted when a role is granted. - /// @param _role The role identifier. /// @param _tokenAddress The token address. /// @param _tokenId The token identifier. - /// @param _grantor The user assigning the role. - /// @param _grantee The user receiving the role. + /// @param _roleId The role identifier. + /// @param _owner The user assigning the role. + /// @param _recipient The user receiving the role. /// @param _expirationDate The expiration date of the role. /// @param _revocable Whether the role is revocable or not. /// @param _data Any additional data about the role. event RoleGranted( - bytes32 indexed _role, address indexed _tokenAddress, uint256 indexed _tokenId, - address _grantor, - address _grantee, + bytes32 indexed _roleId, + address _owner, + address _recipient, uint64 _expirationDate, bool _revocable, bytes _data ); /// @notice Emitted when a role is revoked. - /// @param _role The role identifier. /// @param _tokenAddress The token address. /// @param _tokenId The token identifier. - /// @param _revoker The user revoking the role. - /// @param _grantee The user that receives the role revocation. - event RoleRevoked( - bytes32 indexed _role, - address indexed _tokenAddress, - uint256 indexed _tokenId, - address _revoker, - address _grantee - ); + /// @param _roleId The role identifier. + event RoleRevoked(address indexed _tokenAddress, uint256 indexed _tokenId, bytes32 indexed _roleId); - /// @notice Emitted when a user is approved to manage any role on behalf of another user. + /// @notice Emitted when a user is approved to manage roles on behalf of another user. /// @param _tokenAddress The token address. /// @param _operator The user approved to grant and revoke roles. /// @param _isApproved The approval status. - event RoleApprovalForAll(address indexed _tokenAddress, address indexed _operator, bool _isApproved); + event RoleApprovalForAll(address indexed _tokenAddress, address indexed _operator, bool indexed _isApproved); /** External Functions **/ - /// @notice Grants a role on behalf of a user. - /// @param _roleAssignment The role assignment data. - function grantRoleFrom(RoleAssignment calldata _roleAssignment) external; - - /// @notice Grants a role on behalf of a user. - /// @param _roleAssignment The role assignment data. - function grantRevocableRoleFrom(RoleAssignment calldata _roleAssignment) external; + /// @notice Grants a role to a user. + /// @param _role The role attributes. + function grantRole(Role calldata _role) external; - /// @notice Revokes a role on behalf of a user. - /// @param _role The role identifier. + /// @notice Revokes a role from a user. /// @param _tokenAddress The token address. /// @param _tokenId The token identifier. - /// @param _revoker The user revoking the role. - /// @param _grantee The user that receives the role revocation. - function revokeRoleFrom( - bytes32 _role, - address _tokenAddress, - uint256 _tokenId, - address _revoker, - address _grantee - ) external; + /// @param _roleId The role identifier. + function revokeRole(address _tokenAddress, uint256 _tokenId, bytes32 _roleId) external; - /// @notice Approves operator to grant and revoke any roles on behalf of another user. + /// @notice Approves operator to grant and revoke roles on behalf of another user. /// @param _tokenAddress The token address. /// @param _operator The user approved to grant and revoke roles. /// @param _approved The approval status. @@ -98,81 +72,58 @@ interface IERC7432 is IERC165 { /** View Functions **/ - /// @notice Checks if a user has a role. - /// @param _role The role identifier. + /// @notice Retrieves the recipient of an NFT role. /// @param _tokenAddress The token address. /// @param _tokenId The token identifier. - /// @param _grantor The user that assigned the role. - /// @param _grantee The user that received the role. - function hasNonUniqueRole( - bytes32 _role, + /// @param _roleId The role identifier. + /// @return recipient_ The user that received the role. + function recipientOf( address _tokenAddress, uint256 _tokenId, - address _grantor, - address _grantee - ) external view returns (bool); + bytes32 _roleId + ) external view returns (address recipient_); - /// @notice Checks if a user has a unique role. - /// @param _role The role identifier. + /// @notice Retrieves the custom data of a role assignment. /// @param _tokenAddress The token address. /// @param _tokenId The token identifier. - /// @param _grantor The user that assigned the role. - /// @param _grantee The user that received the role. - function hasRole( - bytes32 _role, + /// @param _roleId The role identifier. + /// @return data_ The custom data of the role. + function roleData( address _tokenAddress, uint256 _tokenId, - address _grantor, - address _grantee - ) external view returns (bool); + bytes32 _roleId + ) external view returns (bytes memory data_); - /// @notice Returns the custom data of a role assignment. - /// @param _role The role identifier. + /// @notice Retrieves the expiration date of a role assignment. /// @param _tokenAddress The token address. /// @param _tokenId The token identifier. - /// @param _grantor The user that assigned the role. - /// @param _grantee The user that received the role. - function roleData( - bytes32 _role, + /// @param _roleId The role identifier. + /// @return expirationDate_ The expiration date of the role. + function roleExpirationDate( address _tokenAddress, uint256 _tokenId, - address _grantor, - address _grantee - ) external view returns (RoleData memory data_); + bytes32 _roleId + ) external view returns (uint64 expirationDate_); - /// @notice Returns the expiration date of a role assignment. - /// @param _role The role identifier. + /// @notice Verifies if the role is revocable. /// @param _tokenAddress The token address. /// @param _tokenId The token identifier. - /// @param _grantor The user that assigned the role. - /// @param _grantee The user that received the role. - function roleExpirationDate( - bytes32 _role, + /// @param _roleId The role identifier. + /// @return revocable_ Whether the role is revocable. + function isRoleRevocable( address _tokenAddress, uint256 _tokenId, - address _grantor, - address _grantee - ) external view returns (uint64 expirationDate_); + bytes32 _roleId + ) external view returns (bool revocable_); - /// @notice Checks if the grantor approved the operator for all NFTs. + /// @notice Verifies if the owner approved the operator. /// @param _tokenAddress The token address. - /// @param _grantor The user that approved the operator. + /// @param _owner The user that approved the operator. /// @param _operator The user that can grant and revoke roles. + /// @return Whether the operator is approved. function isRoleApprovedForAll( address _tokenAddress, - address _grantor, + address _owner, address _operator ) external view returns (bool); - - /// @notice Returns the last grantee of a role. - /// @param _role The role. - /// @param _tokenAddress The token address. - /// @param _tokenId The token ID. - /// @param _grantor The user that granted the role. - function lastGrantee( - bytes32 _role, - address _tokenAddress, - uint256 _tokenId, - address _grantor - ) external view returns (address); } diff --git a/contracts/libraries/LibOriumNftMarketplace.sol b/contracts/libraries/LibOriumNftMarketplace.sol new file mode 100644 index 0000000..eae2e3f --- /dev/null +++ b/contracts/libraries/LibOriumNftMarketplace.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.9; + +import { IERC721 } from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; +import { IERC7432VaultExtension } from '../interfaces/IERC7432VaultExtension.sol'; +import { IOriumMarketplaceRoyalties } from '../interfaces/IOriumMarketplaceRoyalties.sol'; + +/// @dev Rental offer info. +struct RentalOffer { + address lender; + address borrower; + address tokenAddress; + uint256 tokenId; + address feeTokenAddress; + uint256 feeAmountPerSecond; + uint256 nonce; + uint64 deadline; + uint64 minDuration; + bytes32[] roles; + bytes[] rolesData; +} + +library LibOriumNftMarketplace { + /** + * @notice Gets the rental offer hash. + * @dev This function is used to hash the rental offer struct + * @param _offer The rental offer struct to be hashed. + */ + function hashRentalOffer(RentalOffer memory _offer) external pure returns (bytes32) { + return keccak256(abi.encode(_offer)); + } + + /** + * @notice Validates the rental offer. + * @param _offer The rental offer struct to be validated. + */ + function validateCreateRentalOfferParams( + address _oriumMarketplaceRoyalties, + RentalOffer calldata _offer, + uint64 _nonceDeadline + ) external view { + address _rolesRegistry = IOriumMarketplaceRoyalties(_oriumMarketplaceRoyalties).nftRolesRegistryOf( + _offer.tokenAddress + ); + require( + msg.sender == IERC721(_offer.tokenAddress).ownerOf(_offer.tokenId) || + msg.sender == IERC7432VaultExtension(_rolesRegistry).ownerOf(_offer.tokenAddress, _offer.tokenId), + 'OriumNftMarketplace: only token owner can call this function' + ); + require( + IOriumMarketplaceRoyalties(_oriumMarketplaceRoyalties).isTrustedFeeTokenAddressForToken( + _offer.tokenAddress, + _offer.feeTokenAddress + ), + 'OriumNftMarketplace: tokenAddress is not trusted' + ); + require( + _offer.deadline <= block.timestamp + IOriumMarketplaceRoyalties(_oriumMarketplaceRoyalties).maxDuration() && + _offer.deadline > block.timestamp, + 'OriumNftMarketplace: Invalid deadline' + ); + require(_offer.nonce != 0, 'OriumNftMarketplace: Nonce cannot be 0'); + require(msg.sender == _offer.lender, 'OriumNftMarketplace: Sender and Lender mismatch'); + require(_offer.roles.length > 0, 'OriumNftMarketplace: roles should not be empty'); + require( + _offer.roles.length == _offer.rolesData.length, + 'OriumNftMarketplace: roles and rolesData should have the same length' + ); + require( + _offer.borrower != address(0) || _offer.feeAmountPerSecond > 0, + 'OriumNftMarketplace: feeAmountPerSecond should be greater than 0' + ); + require(_offer.minDuration <= _offer.deadline - block.timestamp, 'OriumNftMarketplace: minDuration is invalid'); + require(_nonceDeadline == 0, 'OriumNftMarketplace: nonce already used'); + } +} diff --git a/contracts/mocks/NftRolesRegistryVault.sol b/contracts/mocks/NftRolesRegistryVault.sol new file mode 100644 index 0000000..63fa748 --- /dev/null +++ b/contracts/mocks/NftRolesRegistryVault.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.9; + +import { IERC7432 } from '../interfaces/IERC7432.sol'; +import { IERC7432VaultExtension } from '../interfaces/IERC7432VaultExtension.sol'; +import { IERC721 } from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; + +contract NftRolesRegistryVault is IERC7432, IERC7432VaultExtension { + struct RoleData { + address recipient; + uint64 expirationDate; + bool revocable; + bytes data; + } + + // tokenAddress => tokenId => owner + mapping(address => mapping(uint256 => address)) public originalOwners; + + // tokenAddress => tokenId => roleId => struct(recipient, expirationDate, revocable, data) + mapping(address => mapping(uint256 => mapping(bytes32 => RoleData))) public roles; + + // owner => tokenAddress => operator => isApproved + mapping(address => mapping(address => mapping(address => bool))) public tokenApprovals; + + /** ERC-7432 External Functions **/ + + function grantRole(IERC7432.Role calldata _role) external override { + require(_role.expirationDate > block.timestamp, 'NftRolesRegistryVault: expiration date must be in the future'); + + // deposit NFT if necessary + // reverts if sender is not approved or original owner + address _originalOwner = _depositNft(_role.tokenAddress, _role.tokenId); + + // role must be expired or revocable + RoleData storage _roleData = roles[_role.tokenAddress][_role.tokenId][_role.roleId]; + require( + _roleData.revocable || _roleData.expirationDate < block.timestamp, + 'NftRolesRegistryVault: role must be expired or revocable' + ); + + roles[_role.tokenAddress][_role.tokenId][_role.roleId] = RoleData( + _role.recipient, + _role.expirationDate, + _role.revocable, + _role.data + ); + + emit RoleGranted( + _role.tokenAddress, + _role.tokenId, + _role.roleId, + _originalOwner, + _role.recipient, + _role.expirationDate, + _role.revocable, + _role.data + ); + } + + function revokeRole(address _tokenAddress, uint256 _tokenId, bytes32 _roleId) external override { + address _recipient = roles[_tokenAddress][_tokenId][_roleId].recipient; + address _caller = _getApprovedCaller(_tokenAddress, _tokenId, _recipient); + + // if caller is recipient, the role can be revoked regardless of its state + if (_caller != _recipient) { + // if caller is owner, the role can only be revoked if revocable or expired + require( + roles[_tokenAddress][_tokenId][_roleId].revocable || + roles[_tokenAddress][_tokenId][_roleId].expirationDate < block.timestamp, + 'NftRolesRegistryVault: role is not revocable nor expired' + ); + } + + delete roles[_tokenAddress][_tokenId][_roleId]; + emit RoleRevoked(_tokenAddress, _tokenId, _roleId); + } + + function setRoleApprovalForAll(address _tokenAddress, address _operator, bool _approved) external override { + tokenApprovals[msg.sender][_tokenAddress][_operator] = _approved; + emit RoleApprovalForAll(_tokenAddress, _operator, _approved); + } + + /** ERC-7432 View Functions **/ + + function recipientOf( + address _tokenAddress, + uint256 _tokenId, + bytes32 _roleId + ) external view returns (address recipient_) { + if ( + _isTokenDeposited(_tokenAddress, _tokenId) && + roles[_tokenAddress][_tokenId][_roleId].expirationDate > block.timestamp + ) { + return roles[_tokenAddress][_tokenId][_roleId].recipient; + } + return address(0); + } + + function roleData( + address _tokenAddress, + uint256 _tokenId, + bytes32 _roleId + ) external view returns (bytes memory data_) { + if (!_isTokenDeposited(_tokenAddress, _tokenId)) { + return ''; + } + return roles[_tokenAddress][_tokenId][_roleId].data; + } + + function roleExpirationDate( + address _tokenAddress, + uint256 _tokenId, + bytes32 _roleId + ) external view returns (uint64 expirationDate_) { + if (!_isTokenDeposited(_tokenAddress, _tokenId)) { + return 0; + } + return roles[_tokenAddress][_tokenId][_roleId].expirationDate; + } + + function isRoleRevocable( + address _tokenAddress, + uint256 _tokenId, + bytes32 _roleId + ) external view returns (bool revocable_) { + if (!_isTokenDeposited(_tokenAddress, _tokenId)) { + return false; + } + return roles[_tokenAddress][_tokenId][_roleId].revocable; + } + + function isRoleApprovedForAll(address _tokenAddress, address _owner, address _operator) public view returns (bool) { + return tokenApprovals[_owner][_tokenAddress][_operator]; + } + + /** ERC-7432 Vault Extension Functions **/ + + function withdraw(address _tokenAddress, uint256 _tokenId) external override { + address originalOwner = originalOwners[_tokenAddress][_tokenId]; + + require(_isWithdrawable(_tokenAddress, _tokenId), 'NftRolesRegistryVault: NFT is not withdrawable'); + + require( + originalOwner == msg.sender || isRoleApprovedForAll(_tokenAddress, originalOwner, msg.sender), + 'NftRolesRegistryVault: sender must be owner or approved' + ); + + delete originalOwners[_tokenAddress][_tokenId]; + IERC721(_tokenAddress).transferFrom(address(this), originalOwner, _tokenId); + emit Withdraw(originalOwner, _tokenAddress, _tokenId); + } + + function ownerOf(address _tokenAddress, uint256 _tokenId) external view returns (address owner_) { + return originalOwners[_tokenAddress][_tokenId]; + } + + /** ERC-165 Functions **/ + + function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) { + return interfaceId == type(IERC7432).interfaceId || interfaceId == type(IERC7432VaultExtension).interfaceId; + } + + /** Internal Functions **/ + + /// @notice Updates originalOwner, validates the sender and deposits NFT (if not deposited yet). + /// @param _tokenAddress The token address. + /// @param _tokenId The token identifier. + /// @return originalOwner_ The original owner of the NFT. + function _depositNft(address _tokenAddress, uint256 _tokenId) internal returns (address originalOwner_) { + address _currentOwner = IERC721(_tokenAddress).ownerOf(_tokenId); + + if (_currentOwner == address(this)) { + // if the NFT is already on the contract, check if sender is approved or original owner + originalOwner_ = originalOwners[_tokenAddress][_tokenId]; + require( + originalOwner_ == msg.sender || isRoleApprovedForAll(_tokenAddress, originalOwner_, msg.sender), + 'NftRolesRegistryVault: sender must be owner or approved' + ); + } else { + // if NFT is not in the contract, deposit it and store the original owner + require( + _currentOwner == msg.sender || isRoleApprovedForAll(_tokenAddress, _currentOwner, msg.sender), + 'NftRolesRegistryVault: sender must be owner or approved' + ); + IERC721(_tokenAddress).transferFrom(_currentOwner, address(this), _tokenId); + originalOwners[_tokenAddress][_tokenId] = _currentOwner; + originalOwner_ = _currentOwner; + } + } + + /// @notice Returns the account approved to call the revokeRole function. Reverts otherwise. + /// @param _tokenAddress The token address. + /// @param _tokenId The token identifier. + /// @param _recipient The user that received the role. + /// @return caller_ The approved account. + function _getApprovedCaller( + address _tokenAddress, + uint256 _tokenId, + address _recipient + ) internal view returns (address caller_) { + if (msg.sender == _recipient || isRoleApprovedForAll(_tokenAddress, _recipient, msg.sender)) { + return _recipient; + } + address originalOwner = originalOwners[_tokenAddress][_tokenId]; + if (msg.sender == originalOwner || isRoleApprovedForAll(_tokenAddress, originalOwner, msg.sender)) { + return originalOwner; + } + revert('NftRolesRegistryVault: role does not exist or sender is not approved'); + } + + /// @notice Check if an NFT is withdrawable. + /// @param _tokenAddress The token address. + /// @param _tokenId The token identifier. + /// @return True if the NFT is withdrawable. + function _isWithdrawable(address _tokenAddress, uint256 _tokenId) internal view returns (bool) { + // todo needs to implement a way to track expiration dates to make sure NFTs are withdrawable + // mocked result + return _isTokenDeposited(_tokenAddress, _tokenId); + } + + /// @notice Checks if the NFT is deposited on this contract. + /// @param _tokenAddress The token address. + /// @param _tokenId The token identifier. + /// @return deposited_ Whether the NFT is deposited or not. + function _isTokenDeposited(address _tokenAddress, uint256 _tokenId) internal view returns (bool) { + return originalOwners[_tokenAddress][_tokenId] != address(0); + } +} diff --git a/test/OriumMarketplace.test.ts b/test/OriumMarketplace.test.ts deleted file mode 100644 index f11d7e5..0000000 --- a/test/OriumMarketplace.test.ts +++ /dev/null @@ -1,803 +0,0 @@ -import { ethers } from 'hardhat' -import { loadFixture } from '@nomicfoundation/hardhat-network-helpers' -import { deployMarketplaceContracts } from './fixtures/OriumMarketplaceFixture' -import { expect } from 'chai' -import { toWei } from '../utils/bignumber' -import { DirectRental, FeeInfo, RentalOffer, RoyaltyInfo } from '../utils/types' -import { AddressZero, DIRECT_RENTAL_NONCE, EMPTY_BYTES, ONE_DAY, ONE_HOUR, THREE_MONTHS } from '../utils/constants' -import { randomBytes } from 'crypto' -import { USER_ROLE } from '../utils/roles' -import { hashDirectRental } from '../utils/hash' -import { IERC7432, MockERC20, MockERC721, OriumMarketplace } from '../typechain-types' - -describe('OriumMarketplace', () => { - let marketplace: OriumMarketplace - let rolesRegistry: IERC7432 - let mockERC721: MockERC721 - let mockERC20: MockERC20 - - // 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 - let deployer: Awaited> - let operator: Awaited> - let notOperator: Awaited> - let creator: Awaited> - let creatorTreasury: Awaited> - let lender: Awaited> - let borrower: Awaited> - - // Values to be used across tests - const maxDeadline = THREE_MONTHS - const feeInfo: FeeInfo = { - feePercentageInWei: toWei('5'), - isCustomFee: true, - } - - 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, 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, rolesRegistry, mockERC721, mockERC20] = await loadFixture(deployMarketplaceContracts) - }) - - describe('Main Functions', async () => { - describe('Rental Functions', async () => { - const duration = ONE_HOUR - const tokenId = 1 - - beforeEach(async () => { - await mockERC721.mint(lender.address, tokenId) - await rolesRegistry - .connect(lender) - .setRoleApprovalForAll(await mockERC721.getAddress(), await marketplace.getAddress(), true) - }) - - describe('Rental Offers', async () => { - let rentalOffer: RentalOffer - - beforeEach(async () => { - await marketplace.connect(operator).setCreator(await mockERC721.getAddress(), creator.address) - - const royaltyInfo: RoyaltyInfo = { - creator: creator.address, - royaltyPercentageInWei: toWei('10'), - treasury: creatorTreasury.address, - } - - await marketplace - .connect(creator) - .setRoyaltyInfo(await mockERC721.getAddress(), royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury) - - const blockTimestamp = Number((await ethers.provider.getBlock('latest'))?.timestamp) - - rentalOffer = { - nonce: `0x${randomBytes(32).toString('hex')}`, - lender: lender.address, - borrower: AddressZero, - tokenAddress: await mockERC721.getAddress(), - tokenId, - feeTokenAddress: await mockERC20.getAddress(), - feeAmountPerSecond: toWei('0'), - deadline: blockTimestamp + ONE_DAY, - roles: [USER_ROLE], - rolesData: [EMPTY_BYTES], - } - }) - describe('When Rental Offer is not created', async () => { - describe('Create Rental Offer', async () => { - it('Should create a rental offer', async () => { - await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)) - .to.emit(marketplace, 'RentalOfferCreated') - .withArgs( - rentalOffer.nonce, - rentalOffer.tokenAddress, - rentalOffer.tokenId, - rentalOffer.lender, - rentalOffer.borrower, - 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 = Number((await ethers.provider.getBlock('latest'))?.timestamp) - 1 - await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumMarketplace: Invalid deadline', - ) - }) - it('Should NOT create the same rental offer twice', 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 roles or rolesData are empty', async () => { - rentalOffer.roles = [] - await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumMarketplace: roles should not be empty', - ) - }) - it("Should NOT create a rental offer if nonce is the direct rental's nonce", async () => { - rentalOffer.nonce = DIRECT_RENTAL_NONCE.toString() - await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumMarketplace: Nonce cannot be 0', - ) - }) - }) - }) - describe('When Rental Offer is created', async () => { - beforeEach(async () => { - await marketplace.connect(lender).createRentalOffer(rentalOffer) - }) - describe('Accept Rental Offer', async () => { - it('Should accept a public rental offer', async () => { - const blockTimestamp = Number((await ethers.provider.getBlock('latest'))?.timestamp) - const expirationDate = blockTimestamp + duration + 1 - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs( - rentalOffer.nonce, - rentalOffer.tokenAddress, - rentalOffer.tokenId, - rentalOffer.lender, - borrower.address, - expirationDate, - ) - }) - it('Should accept a private rental offer', async () => { - rentalOffer.borrower = borrower.address - rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` - await marketplace.connect(lender).createRentalOffer(rentalOffer) - const blockTimestamp = Number((await ethers.provider.getBlock('latest'))?.timestamp) - const expirationDate = blockTimestamp + duration + 1 - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs( - rentalOffer.nonce, - rentalOffer.tokenAddress, - rentalOffer.tokenId, - rentalOffer.lender, - borrower.address, - expirationDate, - ) - }) - it('Should accept a rental offer if token has a different registry', async () => { - await marketplace - .connect(operator) - .setRolesRegistry(await mockERC721.getAddress(), await rolesRegistry.getAddress()) - const blockTimestamp = Number((await ethers.provider.getBlock('latest'))?.timestamp) - const expirationDate = blockTimestamp + duration + 1 - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs( - rentalOffer.nonce, - rentalOffer.tokenAddress, - rentalOffer.tokenId, - rentalOffer.lender, - borrower.address, - expirationDate, - ) - }) - it('Should accept a rental offer more than once', async () => { - const rentalExpirationDate1 = Number((await ethers.provider.getBlock('latest'))?.timestamp) + duration + 1 - - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs( - rentalOffer.nonce, - rentalOffer.tokenAddress, - rentalOffer.tokenId, - rentalOffer.lender, - borrower.address, - rentalExpirationDate1, - ) - - await ethers.provider.send('evm_increaseTime', [duration + 1]) - - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs( - rentalOffer.nonce, - rentalOffer.tokenAddress, - rentalOffer.tokenId, - rentalOffer.lender, - borrower.address, - rentalExpirationDate1 + duration + 1, - ) - }) - it('Should accept a rental offer by anyone if borrower is the zero address', async () => { - rentalOffer.borrower = ethers.ZeroAddress - rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` - await marketplace.connect(lender).createRentalOffer(rentalOffer) - - const blockTimestamp = Number((await ethers.provider.getBlock('latest'))?.timestamp) - await expect(marketplace.connect(notOperator).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs( - rentalOffer.nonce, - rentalOffer.tokenAddress, - rentalOffer.tokenId, - rentalOffer.lender, - notOperator.address, - blockTimestamp + duration + 1, - ) - }) - it('Should NOT accept a rental offer if caller is not the borrower', async () => { - rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` - rentalOffer.borrower = borrower.address - await marketplace.connect(lender).createRentalOffer(rentalOffer) - await expect( - marketplace.connect(notOperator).acceptRentalOffer(rentalOffer, duration), - ).to.be.revertedWith('OriumMarketplace: Sender is not allowed to rent this NFT') - }) - it('Should NOT accept a rental offer if offer is expired', async () => { - // move foward in time to expire the offer - const blockTimestamp = Number((await ethers.provider.getBlock('latest'))?.timestamp) - const timeToMove = rentalOffer.deadline - blockTimestamp + 1 - await ethers.provider.send('evm_increaseTime', [timeToMove]) - - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.be.revertedWith( - 'OriumMarketplace: expiration date is greater than offer deadline', - ) - }) - it('Should NOT accept a rental offer if offer is not created', async () => { - rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.be.revertedWith( - 'OriumMarketplace: Offer not created', - ) - }) - it('Should NOT accept a rental offer if expiration date is higher than offer deadline', async () => { - const maxDuration = - rentalOffer.deadline - Number((await ethers.provider.getBlock('latest'))?.timestamp) + 1 - await expect( - marketplace.connect(borrower).acceptRentalOffer(rentalOffer, maxDuration), - ).to.be.revertedWith('OriumMarketplace: expiration date is greater than offer deadline') - }) - it('Should NOT accept a rental offer if expiration date is less than block timestamp', async () => { - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, 0)).to.be.revertedWith( - 'RolesRegistry: expiration date must be in the future', - ) - }) - describe('Fees', async function () { - const feeAmountPerSecond = toWei('1') - const feeAmount = feeAmountPerSecond * BigInt(duration) - - beforeEach(async () => { - rentalOffer.feeAmountPerSecond = feeAmountPerSecond - rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` - await marketplace.connect(lender).createRentalOffer(rentalOffer) - await mockERC20.mint(borrower.address, feeAmount * BigInt(2)) - await mockERC20.connect(borrower).approve(await marketplace.getAddress(), feeAmount * BigInt(2)) - }) - - it('Should accept a rental offer with fee', async () => { - const blockTimestamp = Number((await ethers.provider.getBlock('latest'))?.timestamp) - const expirationDate = blockTimestamp + duration + 1 - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs( - rentalOffer.nonce, - rentalOffer.tokenAddress, - rentalOffer.tokenId, - rentalOffer.lender, - borrower.address, - expirationDate, - ) - .to.emit(mockERC20, 'Transfer') - }) - it('Should accept a rental offer if marketplace fee is 0', async () => { - await marketplace - .connect(operator) - .setMarketplaceFeeForCollection(await mockERC721.getAddress(), 0, true) - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.emit( - marketplace, - 'RentalStarted', - ) - }) - it('Should accept a rental offer if royalty fee is 0', async () => { - await marketplace - .connect(creator) - .setRoyaltyInfo(await mockERC721.getAddress(), '0', creatorTreasury.address) - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.emit( - marketplace, - 'RentalStarted', - ) - }) - it('Should NOT accept a rental offer if marketplace fee transfer fails', async () => { - await mockERC20.transferReverts(true, 0) - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.be.revertedWith( - 'OriumMarketplace: Transfer failed', - ) - }) - it('Should NOT accept a rental offer if royalty fee transfer fails', async () => { - await mockERC20.transferReverts(true, 1) - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.be.revertedWith( - 'OriumMarketplace: Transfer failed', - ) - }) - it('Should NOT accept a rental offer if lender fee transfer fails', async () => { - await mockERC20.transferReverts(true, 2) - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.be.revertedWith( - 'OriumMarketplace: Transfer failed', - ) - }) - it('Should NOT accept a rental offer twice', async () => { - await marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration) - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.be.revertedWith( - 'OriumMarketplace: Rental already started', - ) - }) - }) - }) - describe('Cancel Rental Offer', async () => { - it('Should cancel a rental offer', async () => { - await expect(marketplace.connect(lender).cancelRentalOffer(rentalOffer.nonce)) - .to.emit(marketplace, 'RentalOfferCancelled') - .withArgs(rentalOffer.nonce, lender.address) - }) - it('Should NOT cancel a rental offer if nonce not used yet by caller', async () => { - await expect(marketplace.connect(notOperator).cancelRentalOffer(rentalOffer.nonce)).to.be.revertedWith( - 'OriumMarketplace: Nonce expired or not used yet', - ) - }) - it("Should NOT cancel a rental offer after deadline's expiration", async () => { - // move foward in time to expire the offer - const blockTimestamp = Number((await ethers.provider.getBlock('latest'))?.timestamp) - const timeToMove = rentalOffer.deadline - blockTimestamp + 1 - await ethers.provider.send('evm_increaseTime', [timeToMove]) - - await expect(marketplace.connect(lender).cancelRentalOffer(rentalOffer.nonce)).to.be.revertedWith( - 'OriumMarketplace: Nonce expired or not used yet', - ) - }) - }) - }) - describe('When Rental Offer is accepted', async () => { - beforeEach(async () => { - await marketplace.connect(lender).createRentalOffer(rentalOffer) - await marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration) - await rolesRegistry - .connect(borrower) - .setRoleApprovalForAll(await mockERC721.getAddress(), await marketplace.getAddress(), true) - }) - describe('End Rental', async () => { - it('Should end a rental by the borrower', async () => { - await expect(marketplace.connect(borrower).endRental(rentalOffer)) - .to.emit(marketplace, 'RentalEnded') - .withArgs( - rentalOffer.nonce, - rentalOffer.tokenAddress, - rentalOffer.tokenId, - rentalOffer.lender, - borrower.address, - ) - }) - it('Should NOT end a rental by the lender', async () => { - await expect(marketplace.connect(lender).endRental(rentalOffer)).to.be.revertedWith( - 'OriumMarketplace: Only borrower can end a rental', - ) - }) - it('Should NOT end a rental if caller is not the borrower', async () => { - await expect(marketplace.connect(notOperator).endRental(rentalOffer)).to.be.revertedWith( - 'OriumMarketplace: Only borrower can end a rental', - ) - }) - it('Should NOT end a rental if rental is not started', async () => { - await expect( - marketplace - .connect(borrower) - .endRental({ ...rentalOffer, nonce: `0x${randomBytes(32).toString('hex')}` }), - ).to.be.revertedWith('OriumMarketplace: Offer not created') - }) - it('Should NOT end a rental if rental is expired', async () => { - // move foward in time to expire the offer - const blockTimestamp = Number((await ethers.provider.getBlock('latest'))?.timestamp) - const timeToMove = rentalOffer.deadline - blockTimestamp + 1 - await ethers.provider.send('evm_increaseTime', [timeToMove]) - - await expect(marketplace.connect(borrower).endRental(rentalOffer)).to.be.revertedWith( - 'OriumMarketplace: Rental Offer expired', - ) - }) - it('Should end a rental if the role was revoked by borrower directly in registry', async () => { - await rolesRegistry - .connect(borrower) - .setRoleApprovalForAll(await mockERC721.getAddress(), borrower.address, true) - await rolesRegistry - .connect(borrower) - .revokeRoleFrom( - rentalOffer.roles[0], - rentalOffer.tokenAddress, - rentalOffer.tokenId, - rentalOffer.lender, - borrower.address, - ) - await expect(marketplace.connect(borrower).endRental(rentalOffer)) - .to.emit(marketplace, 'RentalEnded') - .withArgs( - rentalOffer.nonce, - rentalOffer.tokenAddress, - rentalOffer.tokenId, - rentalOffer.lender, - borrower.address, - ) - }) - it('Should NOT end rental twice', async () => { - await marketplace.connect(borrower).endRental(rentalOffer) - await expect(marketplace.connect(borrower).endRental(rentalOffer)).to.be.revertedWith( - 'OriumMarketplace: Rental ended', - ) - }) - }) - }) - }) - - describe('Direct Rentals', async function () { - let directRental: DirectRental - let directRentalHash: string - beforeEach(async () => { - directRental = { - tokenAddress: await mockERC721.getAddress(), - tokenId, - lender: lender.address, - borrower: borrower.address, - duration, - roles: [USER_ROLE], - rolesData: [EMPTY_BYTES], - } - directRentalHash = hashDirectRental(directRental) - }) - describe('Create Direct Rental', async () => { - it("Should create a direct rental if caller is the token's owner", async () => { - await expect(marketplace.connect(lender).createDirectRental(directRental)) - .to.emit(marketplace, 'DirectRentalStarted') - .withArgs( - directRentalHash, - await mockERC721.getAddress(), - tokenId, - lender.address, - borrower.address, - directRental.duration, - directRental.roles, - directRental.rolesData, - ) - }) - it('Should NOT create a direct rental if caller is not the token owner', async () => { - await expect(marketplace.connect(notOperator).createDirectRental(directRental)).to.be.revertedWith( - 'OriumMarketplace: only token owner can call this function', - ) - }) - it("Should NOT create a direct rental if lender address is not the token's owner", async () => { - directRental.lender = creator.address - await expect(marketplace.connect(lender).createDirectRental(directRental)).to.be.revertedWith( - 'OriumMarketplace: Sender and Lender mismatch', - ) - }) - it("Should NOT create a direct rental if roles and rolesData don't have the same length", async () => { - directRental.roles = [`0x${randomBytes(32).toString('hex')}`] - directRental.rolesData = [`0x${randomBytes(32).toString('hex')}`, `0x${randomBytes(32).toString('hex')}`] - await expect(marketplace.connect(lender).createDirectRental(directRental)).to.be.revertedWith( - 'OriumMarketplace: roles and rolesData should have the same length', - ) - }) - it('Should NOT create a direct rental if roles or rolesData are empty', async () => { - directRental.roles = [] - await expect(marketplace.connect(lender).createDirectRental(directRental)).to.be.revertedWith( - 'OriumMarketplace: roles should not be empty', - ) - }) - it('Should NOT create a direct rental if expiration date is greater than maxDeadline', async () => { - directRental.duration += maxDeadline - await expect(marketplace.connect(lender).createDirectRental(directRental)).to.be.revertedWith( - 'OriumMarketplace: Duration is greater than max deadline', - ) - }) - }) - describe('Cancel Direct Rental', async () => { - beforeEach(async () => { - await marketplace.connect(lender).createDirectRental(directRental) - }) - it('Should cancel a direct rental if caller is the lender', async () => { - await expect(marketplace.connect(lender).cancelDirectRental(directRental)) - .to.emit(marketplace, 'DirectRentalEnded') - .withArgs(directRentalHash, lender.address) - }) - it('Should cancel a direct rental if caller is the borrower', async () => { - await rolesRegistry - .connect(borrower) - .setRoleApprovalForAll(await mockERC721.getAddress(), await marketplace.getAddress(), true) - await expect(marketplace.connect(borrower).cancelDirectRental(directRental)) - .to.emit(marketplace, 'DirectRentalEnded') - .withArgs(directRentalHash, lender.address) - }) - it('Should NOT cancel a direct rental if caller is neither borrower or lender', async () => { - await expect(marketplace.connect(notOperator).cancelDirectRental(directRental)).to.be.revertedWith( - 'OriumMarketplace: Sender and Lender/Borrower mismatch', - ) - }) - it('Should NOT cancel a direct rental twice', async () => { - await marketplace.connect(lender).cancelDirectRental(directRental) - await expect(marketplace.connect(lender).cancelDirectRental(directRental)).to.be.revertedWith( - 'OriumMarketplace: Direct rental expired', - ) - }) - it("Should NOT cancel a direct rental if rental doesn't exist", async () => { - await expect( - marketplace.connect(lender).cancelDirectRental({ ...directRental, duration: 0 }), - ).to.be.revertedWith('OriumMarketplace: Direct rental not created') - }) - }) - }) - }) - 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.ZeroAddress, 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 - }) - - 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', - ) - }) - }) - 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(operator) - .setMarketplaceFeeForCollection( - await mockERC721.getAddress(), - feeInfo.feePercentageInWei, - feeInfo.isCustomFee, - ), - ) - .to.emit(marketplace, 'MarketplaceFeeSet') - .withArgs(await mockERC721.getAddress(), feeInfo.feePercentageInWei, feeInfo.isCustomFee) - expect(await marketplace.feeInfo(await mockERC721.getAddress())).to.be.deep.equal([ - feeInfo.feePercentageInWei, - feeInfo.isCustomFee, - ]) - expect(await marketplace.marketplaceFeeOf(await mockERC721.getAddress())).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( - await mockERC721.getAddress(), - 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(await mockERC721.getAddress(), creator.address) - - const royaltyInfo: RoyaltyInfo = { - creator: creator.address, - royaltyPercentageInWei: toWei('10'), - treasury: creatorTreasury.address, - } - - await marketplace - .connect(creator) - .setRoyaltyInfo(await mockERC721.getAddress(), royaltyInfo.royaltyPercentageInWei, royaltyInfo.treasury) - - const feeInfo: FeeInfo = { - feePercentageInWei: toWei('95'), - isCustomFee: true, - } - await expect( - marketplace - .connect(operator) - .setMarketplaceFeeForCollection( - await mockERC721.getAddress(), - 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.ZeroAddress, - } - - await expect(marketplace.connect(operator).setCreator(await mockERC721.getAddress(), creator.address)) - .to.emit(marketplace, 'CreatorRoyaltySet') - .withArgs( - await mockERC721.getAddress(), - creator.address, - royaltyInfo.royaltyPercentageInWei, - royaltyInfo.treasury, - ) - - expect(await marketplace.royaltyInfo(await mockERC721.getAddress())).to.be.deep.equal([ - 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(await mockERC721.getAddress(), creator.address), - ).to.be.revertedWith('Ownable: caller is not the owner') - }) - }) - - describe('Creator', async () => { - beforeEach(async () => { - await marketplace.connect(operator).setCreator(await mockERC721.getAddress(), 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( - await mockERC721.getAddress(), - royaltyInfo.royaltyPercentageInWei, - royaltyInfo.treasury, - ), - ) - .to.emit(marketplace, 'CreatorRoyaltySet') - .withArgs( - await mockERC721.getAddress(), - 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( - await mockERC721.getAddress(), - 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( - await mockERC721.getAddress(), - royaltyInfo.royaltyPercentageInWei, - royaltyInfo.treasury, - ), - ).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', - ) - }) - 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('Roles Registry', async () => { - it('Should set the roles registry for a collection', async () => { - await expect( - marketplace - .connect(operator) - .setRolesRegistry(await mockERC721.getAddress(), await rolesRegistry.getAddress()), - ) - .to.emit(marketplace, 'RolesRegistrySet') - .withArgs(await mockERC721.getAddress(), await rolesRegistry.getAddress()) - }) - it('Should NOT set the roles registry if caller is not the operator', async () => { - await expect( - marketplace - .connect(notOperator) - .setRolesRegistry(await mockERC721.getAddress(), await rolesRegistry.getAddress()), - ).to.be.revertedWith('Ownable: caller is not the owner') - }) - }) - - describe('Default Roles Registry', async () => { - it('Should set the default roles registry for a collection', async () => { - await expect(marketplace.connect(operator).setDefaultRolesRegistry(await rolesRegistry.getAddress())).to.not - .be.reverted - }) - it('Should NOT set the default roles registry if caller is not the operator', async () => { - await expect( - marketplace.connect(notOperator).setDefaultRolesRegistry(await rolesRegistry.getAddress()), - ).to.be.revertedWith('Ownable: caller is not the owner') - }) - }) - }) - }) -}) diff --git a/test/OriumNftMarketplace.test.ts b/test/OriumNftMarketplace.test.ts index 7491952..c4319fc 100644 --- a/test/OriumNftMarketplace.test.ts +++ b/test/OriumNftMarketplace.test.ts @@ -193,7 +193,7 @@ describe('OriumNftMarketplace', () => { .connect(operator) .setTrustedFeeTokenForToken([await mockERC721.getAddress()], [await mockERC20.getAddress()], [false]) await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumSftMarketplace: tokenAddress is not trusted', + 'OriumNftMarketplace: tokenAddress is not trusted', ) }) it('Should NOT create a rental offer if deadline is less than minDuration', async () => { diff --git a/test/fixtures/OriumMarketplaceFixture.ts b/test/fixtures/OriumMarketplaceFixture.ts deleted file mode 100644 index d6d5519..0000000 --- a/test/fixtures/OriumMarketplaceFixture.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ethers, upgrades } from 'hardhat' -import { RolesRegistryAddress, THREE_MONTHS } from '../../utils/constants' -import { IERC7432, MockERC20, MockERC721, OriumMarketplace } from '../../typechain-types' -/** - * @dev deployer, operator needs to be the first accounts in the hardhat ethers.getSigners() - * list respectively. This should be considered to use this fixture in tests - * @returns [marketplace, rolesRegistry, mockERC721, mockERC20] - */ -export async function deployMarketplaceContracts() { - const [, operator] = await ethers.getSigners() - - const rolesRegistry: IERC7432 = await ethers.getContractAt('IERC7432', RolesRegistryAddress) - - const MarketplaceFactory = await ethers.getContractFactory('OriumMarketplace') - const marketplaceProxy = await upgrades.deployProxy(MarketplaceFactory, [ - operator.address, - await rolesRegistry.getAddress(), - THREE_MONTHS, - ]) - await marketplaceProxy.waitForDeployment() - - const marketplace: OriumMarketplace = await ethers.getContractAt( - 'OriumMarketplace', - await marketplaceProxy.getAddress(), - ) - - const MockERC721Factory = await ethers.getContractFactory('MockERC721') - const mockERC721: MockERC721 = await MockERC721Factory.deploy() - await mockERC721.waitForDeployment() - - const MockERC20Factory = await ethers.getContractFactory('MockERC20') - const mockERC20: MockERC20 = await MockERC20Factory.deploy() - await mockERC20.waitForDeployment() - - return [marketplace, rolesRegistry, mockERC721, mockERC20] as const -} diff --git a/test/fixtures/OriumNftMarketplaceFixture.ts b/test/fixtures/OriumNftMarketplaceFixture.ts index ad55468..009b29c 100644 --- a/test/fixtures/OriumNftMarketplaceFixture.ts +++ b/test/fixtures/OriumNftMarketplaceFixture.ts @@ -1,6 +1,12 @@ import { ethers, upgrades } from 'hardhat' -import { AddressZero, RolesRegistryAddress, THREE_MONTHS } from '../../utils/constants' -import { IERC7432, MockERC20, MockERC721, OriumMarketplaceRoyalties, OriumNftMarketplace } from '../../typechain-types' +import { AddressZero, THREE_MONTHS } from '../../utils/constants' +import { + NftRolesRegistryVault, + MockERC20, + MockERC721, + OriumMarketplaceRoyalties, + OriumNftMarketplace, +} from '../../typechain-types' /** * @dev deployer, operator needs to be the first accounts in the hardhat ethers.getSigners() * list respectively. This should be considered to use this fixture in tests @@ -9,7 +15,9 @@ import { IERC7432, MockERC20, MockERC721, OriumMarketplaceRoyalties, OriumNftMar export async function deployNftMarketplaceContracts() { const [, operator] = await ethers.getSigners() - const rolesRegistry: IERC7432 = await ethers.getContractAt('IERC7432', RolesRegistryAddress) + const RolesRegistryFactory = await ethers.getContractFactory('NftRolesRegistryVault') + const rolesRegistry: NftRolesRegistryVault = await RolesRegistryFactory.deploy() + await rolesRegistry.waitForDeployment() const MarketplaceRoyaltiesFactory = await ethers.getContractFactory('OriumMarketplaceRoyalties') const marketplaceRoyaltiesProxy = await upgrades.deployProxy(MarketplaceRoyaltiesFactory, [ @@ -23,12 +31,22 @@ export async function deployNftMarketplaceContracts() { 'OriumMarketplaceRoyalties', await marketplaceRoyaltiesProxy.getAddress(), ) + const LibMarketplaceFactory = await ethers.getContractFactory('LibOriumNftMarketplace') + const libMarketplace = await LibMarketplaceFactory.deploy() + await libMarketplace.waitForDeployment() - const MarketplaceFactory = await ethers.getContractFactory('OriumNftMarketplace') - const marketplaceProxy = await upgrades.deployProxy(MarketplaceFactory, [ - operator.address, - await marketplaceRoyalties.getAddress(), - ]) + const MarketplaceFactory = await ethers.getContractFactory('OriumNftMarketplace', { + libraries: { + LibOriumNftMarketplace: await libMarketplace.getAddress(), + }, + }) + const marketplaceProxy = await upgrades.deployProxy( + MarketplaceFactory, + [operator.address, await marketplaceRoyalties.getAddress()], + { + unsafeAllowLinkedLibraries: true, + }, + ) await marketplaceProxy.waitForDeployment() const marketplace: OriumNftMarketplace = await ethers.getContractAt( From cf0f6f51c8470989a6a8333a8e8e2d34bc368312 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Fri, 19 Apr 2024 09:53:02 -0300 Subject: [PATCH 06/11] ON-812: PR fixes --- contracts/OriumNftMarketplace.sol | 15 ++++----- ...tplace.sol => LibNftRentalMarketplace.sol} | 30 +++++++++-------- test/OriumNftMarketplace.test.ts | 32 +++++++++---------- ...ture.ts => NftRentalMarketplaceFixture.ts} | 12 +++---- 4 files changed, 45 insertions(+), 44 deletions(-) rename contracts/libraries/{LibOriumNftMarketplace.sol => LibNftRentalMarketplace.sol} (62%) rename test/fixtures/{OriumNftMarketplaceFixture.ts => NftRentalMarketplaceFixture.ts} (88%) diff --git a/contracts/OriumNftMarketplace.sol b/contracts/OriumNftMarketplace.sol index da2a73c..53ffa9f 100644 --- a/contracts/OriumNftMarketplace.sol +++ b/contracts/OriumNftMarketplace.sol @@ -8,14 +8,14 @@ import { IOriumMarketplaceRoyalties } from './interfaces/IOriumMarketplaceRoyalt 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 { LibOriumNftMarketplace, RentalOffer } from './libraries/LibOriumNftMarketplace.sol'; +import { LibNftRentalMarketplace, RentalOffer } from './libraries/LibNftRentalMarketplace.sol'; /** * @title Orium NFT 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 OriumNftMarketplace is Initializable, OwnableUpgradeable, PausableUpgradeable { +contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgradeable { /** ######### Global Variables ########### **/ /// @dev oriumMarketplaceRoyalties stores the collection royalties and fees @@ -84,22 +84,21 @@ contract OriumNftMarketplace is Initializable, OwnableUpgradeable, PausableUpgra */ function createRentalOffer( RentalOffer calldata _offer - ) external { - LibOriumNftMarketplace.validateCreateRentalOfferParams( + ) external whenNotPaused { + LibNftRentalMarketplace.validateCreateRentalOfferParams( oriumMarketplaceRoyalties, _offer, nonceDeadline[msg.sender][_offer.nonce] ); - bytes32 _offerHash = LibOriumNftMarketplace.hashRentalOffer(_offer); - - nonceDeadline[msg.sender][_offer.nonce] = _offer.deadline; + bytes32 _offerHash = LibNftRentalMarketplace.hashRentalOffer(_offer); isCreated[_offerHash] = true; + nonceDeadline[msg.sender][_offer.nonce] = _offer.deadline; for (uint256 i = 0; i < _offer.roles.length; i++) { require( roleDeadline[_offer.roles[i]][_offer.tokenAddress][_offer.tokenId] < block.timestamp, - 'OriumNftMarketplace: role still has an active offer' + 'NftRentalMarketplace: role still has an active offer' ); roleDeadline[_offer.roles[i]][_offer.tokenAddress][_offer.tokenId] = _offer.deadline; } diff --git a/contracts/libraries/LibOriumNftMarketplace.sol b/contracts/libraries/LibNftRentalMarketplace.sol similarity index 62% rename from contracts/libraries/LibOriumNftMarketplace.sol rename to contracts/libraries/LibNftRentalMarketplace.sol index eae2e3f..45f9bbc 100644 --- a/contracts/libraries/LibOriumNftMarketplace.sol +++ b/contracts/libraries/LibNftRentalMarketplace.sol @@ -21,7 +21,7 @@ struct RentalOffer { bytes[] rolesData; } -library LibOriumNftMarketplace { +library LibNftRentalMarketplace { /** * @notice Gets the rental offer hash. * @dev This function is used to hash the rental offer struct @@ -40,38 +40,40 @@ library LibOriumNftMarketplace { RentalOffer calldata _offer, uint64 _nonceDeadline ) external view { - address _rolesRegistry = IOriumMarketplaceRoyalties(_oriumMarketplaceRoyalties).nftRolesRegistryOf( + address _rolesRegistry = IOriumMarketplaceRoyalties(_oriumMarketplaceRoyalties).nftRolesRegistryOf( _offer.tokenAddress ); + address _nftOwner = IERC721(_offer.tokenAddress).ownerOf(_offer.tokenId); require( - msg.sender == IERC721(_offer.tokenAddress).ownerOf(_offer.tokenId) || - msg.sender == IERC7432VaultExtension(_rolesRegistry).ownerOf(_offer.tokenAddress, _offer.tokenId), - 'OriumNftMarketplace: only token owner can call this function' + msg.sender == _nftOwner || + (msg.sender == IERC7432VaultExtension(_rolesRegistry).ownerOf(_offer.tokenAddress, _offer.tokenId) && + _rolesRegistry == _nftOwner), + 'NftRentalMarketplace: only token owner can call this function' ); require( IOriumMarketplaceRoyalties(_oriumMarketplaceRoyalties).isTrustedFeeTokenAddressForToken( _offer.tokenAddress, _offer.feeTokenAddress ), - 'OriumNftMarketplace: tokenAddress is not trusted' + 'NftRentalMarketplace: tokenAddress or feeTokenAddress is not trusted' ); require( _offer.deadline <= block.timestamp + IOriumMarketplaceRoyalties(_oriumMarketplaceRoyalties).maxDuration() && _offer.deadline > block.timestamp, - 'OriumNftMarketplace: Invalid deadline' + 'NftRentalMarketplace: Invalid deadline' ); - require(_offer.nonce != 0, 'OriumNftMarketplace: Nonce cannot be 0'); - require(msg.sender == _offer.lender, 'OriumNftMarketplace: Sender and Lender mismatch'); - require(_offer.roles.length > 0, 'OriumNftMarketplace: roles should not be empty'); + require(_offer.nonce != 0, 'NftRentalMarketplace: Nonce cannot be 0'); + require(msg.sender == _offer.lender, 'NftRentalMarketplace: Sender and Lender mismatch'); + require(_offer.roles.length > 0, 'NftRentalMarketplace: roles should not be empty'); require( _offer.roles.length == _offer.rolesData.length, - 'OriumNftMarketplace: roles and rolesData should have the same length' + 'NftRentalMarketplace: roles and rolesData should have the same length' ); require( _offer.borrower != address(0) || _offer.feeAmountPerSecond > 0, - 'OriumNftMarketplace: feeAmountPerSecond should be greater than 0' + 'NftRentalMarketplace: feeAmountPerSecond should be greater than 0' ); - require(_offer.minDuration <= _offer.deadline - block.timestamp, 'OriumNftMarketplace: minDuration is invalid'); - require(_nonceDeadline == 0, 'OriumNftMarketplace: nonce already used'); + require(_offer.minDuration <= _offer.deadline - block.timestamp, 'NftRentalMarketplace: minDuration is invalid'); + require(_nonceDeadline == 0, 'NftRentalMarketplace: nonce already used'); } } diff --git a/test/OriumNftMarketplace.test.ts b/test/OriumNftMarketplace.test.ts index c4319fc..2c6c12a 100644 --- a/test/OriumNftMarketplace.test.ts +++ b/test/OriumNftMarketplace.test.ts @@ -6,11 +6,11 @@ import { RentalOffer } from '../utils/types' import { AddressZero, EMPTY_BYTES, ONE_DAY, THREE_MONTHS } from '../utils/constants' import { randomBytes } from 'crypto' import { USER_ROLE } from '../utils/roles' -import { IERC7432, MockERC20, MockERC721, OriumMarketplaceRoyalties, OriumNftMarketplace } from '../typechain-types' -import { deployNftMarketplaceContracts } from './fixtures/OriumNftMarketplaceFixture' +import { IERC7432, MockERC20, MockERC721, OriumMarketplaceRoyalties, NftRentalMarketplace } from '../typechain-types' +import { deployNftMarketplaceContracts } from './fixtures/NftRentalMarketplaceFixture' -describe('OriumNftMarketplace', () => { - let marketplace: OriumNftMarketplace +describe('NftRentalMarketplace', () => { + let marketplace: NftRentalMarketplace let marketplaceRoyalties: OriumMarketplaceRoyalties let rolesRegistry: IERC7432 let mockERC721: MockERC721 @@ -136,56 +136,56 @@ describe('OriumNftMarketplace', () => { }) it('Should NOT create a rental offer if caller is not the lender', async () => { await expect(marketplace.connect(notOperator).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumNftMarketplace: only token owner can call this function', + 'NftRentalMarketplace: 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( - 'OriumNftMarketplace: Sender and Lender mismatch', + 'NftRentalMarketplace: 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( - 'OriumNftMarketplace: roles and rolesData should have the same length', + 'NftRentalMarketplace: 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( - 'OriumNftMarketplace: Invalid deadline', + 'NftRentalMarketplace: Invalid deadline', ) }) it("Should NOT create a rental offer if deadline is less than block's timestamp", async () => { rentalOffer.deadline = Number((await ethers.provider.getBlock('latest'))?.timestamp) - 1 await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumNftMarketplace: Invalid deadline', + 'NftRentalMarketplace: Invalid deadline', ) }) it('Should NOT create the same rental offer twice', async () => { await marketplace.connect(lender).createRentalOffer(rentalOffer) await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumNftMarketplace: nonce already used', + 'NftRentalMarketplace: nonce already used', ) }) it('Should NOT create a rental offer if roles or rolesData are empty', async () => { rentalOffer.roles = [] await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumNftMarketplace: roles should not be empty', + 'NftRentalMarketplace: roles should not be empty', ) }) it('Should NOT create a rental offer if nonce is zero', async () => { rentalOffer.nonce = '0' await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumNftMarketplace: Nonce cannot be 0', + 'NftRentalMarketplace: Nonce cannot be 0', ) }) it('Should NOT create a rental offer if feeAmountPerSecond is zero', async () => { rentalOffer.feeAmountPerSecond = BigInt(0) await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumNftMarketplace: feeAmountPerSecond should be greater than 0', + 'NftRentalMarketplace: feeAmountPerSecond should be greater than 0', ) }) it('Should NOT create a rental offer if tokenAddress is not trusted', async () => { @@ -193,20 +193,20 @@ describe('OriumNftMarketplace', () => { .connect(operator) .setTrustedFeeTokenForToken([await mockERC721.getAddress()], [await mockERC20.getAddress()], [false]) await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumNftMarketplace: tokenAddress is not trusted', + 'NftRentalMarketplace: tokenAddress or feeTokenAddress is not trusted', ) }) it('Should NOT create a rental offer if deadline is less than minDuration', async () => { rentalOffer.minDuration = ONE_DAY * 2 await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumNftMarketplace: minDuration is invalid', + 'NftRentalMarketplace: minDuration is invalid', ) }) it('Should NOT create more than one rental offer for the same role', async () => { await marketplace.connect(lender).createRentalOffer(rentalOffer) rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( - 'OriumNftMarketplace: role still has an active offer', + 'NftRentalMarketplace: role still has an active offer', ) }) }) diff --git a/test/fixtures/OriumNftMarketplaceFixture.ts b/test/fixtures/NftRentalMarketplaceFixture.ts similarity index 88% rename from test/fixtures/OriumNftMarketplaceFixture.ts rename to test/fixtures/NftRentalMarketplaceFixture.ts index 009b29c..f40b47c 100644 --- a/test/fixtures/OriumNftMarketplaceFixture.ts +++ b/test/fixtures/NftRentalMarketplaceFixture.ts @@ -5,7 +5,7 @@ import { MockERC20, MockERC721, OriumMarketplaceRoyalties, - OriumNftMarketplace, + NftRentalMarketplace, } from '../../typechain-types' /** * @dev deployer, operator needs to be the first accounts in the hardhat ethers.getSigners() @@ -31,13 +31,13 @@ export async function deployNftMarketplaceContracts() { 'OriumMarketplaceRoyalties', await marketplaceRoyaltiesProxy.getAddress(), ) - const LibMarketplaceFactory = await ethers.getContractFactory('LibOriumNftMarketplace') + const LibMarketplaceFactory = await ethers.getContractFactory('LibNftRentalMarketplace') const libMarketplace = await LibMarketplaceFactory.deploy() await libMarketplace.waitForDeployment() - const MarketplaceFactory = await ethers.getContractFactory('OriumNftMarketplace', { + const MarketplaceFactory = await ethers.getContractFactory('NftRentalMarketplace', { libraries: { - LibOriumNftMarketplace: await libMarketplace.getAddress(), + LibNftRentalMarketplace: await libMarketplace.getAddress(), }, }) const marketplaceProxy = await upgrades.deployProxy( @@ -49,8 +49,8 @@ export async function deployNftMarketplaceContracts() { ) await marketplaceProxy.waitForDeployment() - const marketplace: OriumNftMarketplace = await ethers.getContractAt( - 'OriumNftMarketplace', + const marketplace: NftRentalMarketplace = await ethers.getContractAt( + 'NftRentalMarketplace', await marketplaceProxy.getAddress(), ) From 35249759c289590fb982206cd4e9f6958dd1552f Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Fri, 19 Apr 2024 09:53:56 -0300 Subject: [PATCH 07/11] ON-812: PR fixes --- contracts/{OriumNftMarketplace.sol => NftRentalMarketplace.sol} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename contracts/{OriumNftMarketplace.sol => NftRentalMarketplace.sol} (100%) diff --git a/contracts/OriumNftMarketplace.sol b/contracts/NftRentalMarketplace.sol similarity index 100% rename from contracts/OriumNftMarketplace.sol rename to contracts/NftRentalMarketplace.sol From acfe7906ac5ab4bd16aed67d8a4e7bf1efbd7198 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Fri, 19 Apr 2024 15:33:07 -0300 Subject: [PATCH 08/11] ON-812: update test file name --- .../{OriumNftMarketplace.test.ts => NftRentalMarketplace.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/{OriumNftMarketplace.test.ts => NftRentalMarketplace.test.ts} (100%) diff --git a/test/OriumNftMarketplace.test.ts b/test/NftRentalMarketplace.test.ts similarity index 100% rename from test/OriumNftMarketplace.test.ts rename to test/NftRentalMarketplace.test.ts From 87b0574fd07bb8344e3dcf1511a73a484c4f29f3 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Fri, 19 Apr 2024 17:17:16 -0300 Subject: [PATCH 09/11] ON-812: increase coverage --- test/NftRentalMarketplace.test.ts | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/NftRentalMarketplace.test.ts b/test/NftRentalMarketplace.test.ts index 2c6c12a..e9dab70 100644 --- a/test/NftRentalMarketplace.test.ts +++ b/test/NftRentalMarketplace.test.ts @@ -134,6 +134,40 @@ describe('NftRentalMarketplace', () => { rentalOffer.rolesData, ) }) + it('Should create rental offer if token is already deposited in rolesRegistry', async function () { + await mockERC721.connect(lender).approve(await rolesRegistry.getAddress(), tokenId) + await rolesRegistry.connect(lender).grantRole({ + roleId: USER_ROLE, + tokenAddress: await mockERC721.getAddress(), + tokenId, + recipient: lender.address, + expirationDate: (await time.latest()) + ONE_DAY, + revocable: true, + data: EMPTY_BYTES, + }) + await rolesRegistry.connect(lender).revokeRole(rentalOffer.tokenAddress, rentalOffer.tokenId, USER_ROLE) + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)) + .to.emit(marketplace, 'RentalOfferCreated') + .withArgs( + rentalOffer.nonce, + rentalOffer.tokenAddress, + rentalOffer.tokenId, + rentalOffer.lender, + rentalOffer.borrower, + rentalOffer.feeTokenAddress, + rentalOffer.feeAmountPerSecond, + rentalOffer.deadline, + rentalOffer.minDuration, + rentalOffer.roles, + rentalOffer.rolesData, + ) + }) + it('Should NOT create a rental offer if contract is paused', async () => { + await marketplace.connect(operator).pause() + await expect(marketplace.connect(lender).createRentalOffer(rentalOffer)).to.be.revertedWith( + 'Pausable: paused', + ) + }) it('Should NOT create a rental offer if caller is not the lender', async () => { await expect(marketplace.connect(notOperator).createRentalOffer(rentalOffer)).to.be.revertedWith( 'NftRentalMarketplace: only token owner can call this function', From a1521128b78bbd63501f1fa0c6c8ca2f6c47247b Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Fri, 19 Apr 2024 17:20:24 -0300 Subject: [PATCH 10/11] ON-812: prettier --- contracts/NftRentalMarketplace.sol | 6 ++---- contracts/libraries/LibNftRentalMarketplace.sol | 5 ++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/contracts/NftRentalMarketplace.sol b/contracts/NftRentalMarketplace.sol index 53ffa9f..a722ed0 100644 --- a/contracts/NftRentalMarketplace.sol +++ b/contracts/NftRentalMarketplace.sol @@ -82,9 +82,7 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr * @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 whenNotPaused { + function createRentalOffer(RentalOffer calldata _offer) external whenNotPaused { LibNftRentalMarketplace.validateCreateRentalOfferParams( oriumMarketplaceRoyalties, _offer, @@ -94,7 +92,7 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr bytes32 _offerHash = LibNftRentalMarketplace.hashRentalOffer(_offer); isCreated[_offerHash] = true; nonceDeadline[msg.sender][_offer.nonce] = _offer.deadline; - + for (uint256 i = 0; i < _offer.roles.length; i++) { require( roleDeadline[_offer.roles[i]][_offer.tokenAddress][_offer.tokenId] < block.timestamp, diff --git a/contracts/libraries/LibNftRentalMarketplace.sol b/contracts/libraries/LibNftRentalMarketplace.sol index 45f9bbc..578b9e4 100644 --- a/contracts/libraries/LibNftRentalMarketplace.sol +++ b/contracts/libraries/LibNftRentalMarketplace.sol @@ -73,7 +73,10 @@ library LibNftRentalMarketplace { _offer.borrower != address(0) || _offer.feeAmountPerSecond > 0, 'NftRentalMarketplace: feeAmountPerSecond should be greater than 0' ); - require(_offer.minDuration <= _offer.deadline - block.timestamp, 'NftRentalMarketplace: minDuration is invalid'); + require( + _offer.minDuration <= _offer.deadline - block.timestamp, + 'NftRentalMarketplace: minDuration is invalid' + ); require(_nonceDeadline == 0, 'NftRentalMarketplace: nonce already used'); } } From 4b4fd51c2b5b4e803f0bd283420964030505fc07 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Mon, 22 Apr 2024 11:34:50 -0300 Subject: [PATCH 11/11] ON-812: PR fixes --- contracts/NftRentalMarketplace.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/NftRentalMarketplace.sol b/contracts/NftRentalMarketplace.sol index a722ed0..39deca0 100644 --- a/contracts/NftRentalMarketplace.sol +++ b/contracts/NftRentalMarketplace.sol @@ -89,10 +89,6 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr nonceDeadline[msg.sender][_offer.nonce] ); - bytes32 _offerHash = LibNftRentalMarketplace.hashRentalOffer(_offer); - isCreated[_offerHash] = true; - nonceDeadline[msg.sender][_offer.nonce] = _offer.deadline; - for (uint256 i = 0; i < _offer.roles.length; i++) { require( roleDeadline[_offer.roles[i]][_offer.tokenAddress][_offer.tokenId] < block.timestamp, @@ -101,6 +97,10 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr roleDeadline[_offer.roles[i]][_offer.tokenAddress][_offer.tokenId] = _offer.deadline; } + bytes32 _offerHash = LibNftRentalMarketplace.hashRentalOffer(_offer); + isCreated[_offerHash] = true; + nonceDeadline[msg.sender][_offer.nonce] = _offer.deadline; + emit RentalOfferCreated( _offer.nonce, _offer.tokenAddress,