From 3f6b23795703b6b314422671048e79be623a0594 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Wed, 17 Apr 2024 15:01:14 -0300 Subject: [PATCH 01/11] ON-813: ERC721 Marketplace - Accept Rental Offer --- .../libraries/LibOriumNftMarketplace.sol | 201 ++++++++ test/OriumNftMarketplace.test.ts | 484 ++++++++++++++++++ 2 files changed, 685 insertions(+) create mode 100644 contracts/libraries/LibOriumNftMarketplace.sol create mode 100644 test/OriumNftMarketplace.test.ts diff --git a/contracts/libraries/LibOriumNftMarketplace.sol b/contracts/libraries/LibOriumNftMarketplace.sol new file mode 100644 index 0000000..36e81cd --- /dev/null +++ b/contracts/libraries/LibOriumNftMarketplace.sol @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.9; + +import { IERC7589 } from '../interfaces/IERC7589.sol'; +import { IOriumMarketplaceRoyalties } from '../interfaces/IOriumMarketplaceRoyalties.sol'; +import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import { IERC7432 } from '../interfaces/IERC7432.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; +} + +/// @dev Rental info. +struct Rental { + address borrower; + uint64 expirationDate; +} + +library LibOriumNftMarketplace { + /// @dev 100 ether is 100% + uint256 public constant MAX_PERCENTAGE = 100 ether; + + /** + * @notice Gets the rental offer hash. + * @dev This function is used to hash the rental offer struct with retrocompatibility. + * is only used for reading the hash from the storage. + * @param _offer The rental offer struct to be hashed. + */ + function hashRentalOffer(RentalOffer memory _offer) external pure returns (bytes32) { + return keccak256(abi.encode(_offer)); + } + + /** + * @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) public pure returns (uint256) { + return (_amount * _percentage) / MAX_PERCENTAGE; + } + + /** + * @notice Validates the rental offer. + * @param _offer The rental offer struct to be validated. + */ + function validateCreateRentalOfferParams( + address _oriumMarketplaceRoyalties, + RentalOffer memory _offer, + uint64 _nonceDeadline + ) external view { + 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'); + } + + /** + * @notice Transfers the fees. + * @dev The fee token address should be approved before calling this function. + * @param _feeTokenAddress The fee token address. + * @param _marketplaceTreasuryAddress The marketplace treasury address. + * @param _lenderAddress The lender address. + * @param _royaltiesAddress The Orium marketplace royalties contract address. + * @param _tokenAddress The token address. + * @param _feeAmountPerSecond The fee amount per second. + * @param _duration The duration of the rental. + */ + function transferFees( + address _feeTokenAddress, + address _marketplaceTreasuryAddress, + address _lenderAddress, + address _royaltiesAddress, + address _tokenAddress, + uint256 _feeAmountPerSecond, + uint64 _duration + ) external { + uint256 _totalAmount = _feeAmountPerSecond * _duration; + if (_totalAmount == 0) return; + + IOriumMarketplaceRoyalties _royalties = IOriumMarketplaceRoyalties(_royaltiesAddress); + uint256 _marketplaceFeePercentageInWei = _royalties.marketplaceFeeOf(_tokenAddress); + IOriumMarketplaceRoyalties.RoyaltyInfo memory _royaltyInfo = _royalties.royaltyInfoOf(_tokenAddress); + + uint256 _marketplaceAmount = getAmountFromPercentage(_totalAmount, _marketplaceFeePercentageInWei); + uint256 _royaltyAmount = getAmountFromPercentage(_totalAmount, _royaltyInfo.royaltyPercentageInWei); + uint256 _lenderAmount = _totalAmount - _royaltyAmount - _marketplaceAmount; + + _transferAmount(_feeTokenAddress, msg.sender, _marketplaceTreasuryAddress, _marketplaceAmount); + _transferAmount(_feeTokenAddress, msg.sender, _royaltyInfo.treasury, _royaltyAmount); + _transferAmount(_feeTokenAddress, msg.sender, _lenderAddress, _lenderAmount); + } + + /** + * @notice Transfers an amount to a receipient. + * @dev This function is used to make an ERC20 transfer. + * @param _tokenAddress The token address. + * @param _from The sender address. + * @param _to The recipient address. + * @param _amount The amount to transfer. + */ + function _transferAmount(address _tokenAddress, address _from, address _to, uint256 _amount) internal { + if (_amount == 0) return; + require(IERC20(_tokenAddress).transferFrom(_from, _to, _amount), 'OriumNftMarketplace: Transfer failed'); + } + + function validateAcceptRentalOfferParams( + address _borrower, + uint64 _minDuration, + bool _isCreated, + uint64 _previousRentalExpirationDate, + uint64 _duration, + uint256 _nonceDeadline, + uint64 _expirationDate + ) external view { + require(_isCreated, 'OriumNftMarketplace: Offer not created'); + require( + _previousRentalExpirationDate <= block.timestamp, + 'OriumNftMarketplace: This offer has an ongoing rental' + ); + require(_duration >= _minDuration, 'OriumNftMarketplace: Duration is less than the offer minimum duration'); + require( + _nonceDeadline > _expirationDate, + 'OriumNftMarketplace: expiration date is greater than offer deadline' + ); + require( + address(0) == _borrower || msg.sender == _borrower, + 'OriumNftMarketplace: Sender is not allowed to rent this NFT' + ); + } + + /** + * @notice Batch grants roles. + * @dev This function is used to batch grant roles. + * @param _oriumMarketplaceRoyalties The Orium marketplace royalties contract address. + * @param _tokenAddress The token address. + * @param _tokenId The token id. + * @param _recipient The recipient address. + * @param _expirationDate The expiration date. + * @param _roleIds The role ids. + * @param _data The data. + */ + function batchGrantRole( + address _oriumMarketplaceRoyalties, + address _tokenAddress, + uint256 _tokenId, + address _recipient, + uint64 _expirationDate, + bytes32[] calldata _roleIds, + bytes[] calldata _data + ) external { + address _rolesRegsitry = IOriumMarketplaceRoyalties(_oriumMarketplaceRoyalties).nftRolesRegistryOf( + _tokenAddress + ); + + for (uint256 i = 0; i < _roleIds.length; i++) { + IERC7432(_rolesRegsitry).grantRole( + IERC7432.Role({ + roleId: _roleIds[i], + tokenAddress: _tokenAddress, + tokenId: _tokenId, + recipient: _recipient, + expirationDate: _expirationDate, + revocable: false, + data: _data[i] + }) + ); + } + } +} diff --git a/test/OriumNftMarketplace.test.ts b/test/OriumNftMarketplace.test.ts new file mode 100644 index 0000000..6b9149d --- /dev/null +++ b/test/OriumNftMarketplace.test.ts @@ -0,0 +1,484 @@ +/* eslint-disable no-unexpected-multiline */ +import { ethers } from 'hardhat' +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers' +import { expect } from 'chai' +import { toWei } from '../utils/bignumber' +import { RentalOffer, RoyaltyInfo } from '../utils/types' +import { AddressZero, EMPTY_BYTES, ONE_DAY, ONE_HOUR, 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 creatorTreasury: Awaited> + let lender: Awaited> + let borrower: Awaited> + + // Values to be used across tests + const maxDeadline = THREE_MONTHS + const tokenId = 1 + const duration = ONE_HOUR + + 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, 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 () => { + await marketplaceRoyalties + .connect(operator) + .setRoyaltyInfo(creator.address, await mockERC721.getAddress(), 0, AddressZero) + + const royaltyInfo: RoyaltyInfo = { + creator: creator.address, + royaltyPercentageInWei: toWei('10'), + treasury: creatorTreasury.address, + } + + await marketplaceRoyalties + .connect(creator) + .setRoyaltyInfo( + creator.address, + await mockERC721.getAddress(), + royaltyInfo.royaltyPercentageInWei, + royaltyInfo.treasury, + ) + + const blockTimestamp = Number(await time.latest()) + + 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 time.latest()) + 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 time.latest()) - 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( + 'OriumNftMarketplace: 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('When Rental Offer is created', async () => { + let totalFeeAmount: bigint + beforeEach(async () => { + totalFeeAmount = rentalOffer.feeAmountPerSecond * BigInt(duration) + await marketplace.connect(lender).createRentalOffer(rentalOffer) + await mockERC20.mint(borrower.address, totalFeeAmount.toString()) + await mockERC20.connect(borrower).approve(await marketplace.getAddress(), totalFeeAmount.toString()) + await mockERC721.connect(lender).setApprovalForAll(await rolesRegistry.getAddress(), true) + }) + describe('Accept Rental Offer', async () => { + it('Should accept a public rental offer', async () => { + const blockTimestamp = await time.latest() + const expirationDate = Number(blockTimestamp) + duration + 1 + await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) + .to.emit(marketplace, 'RentalStarted') + .withArgs(rentalOffer.lender, rentalOffer.nonce, borrower.address, expirationDate) + }) + it('Should accept a private rental offer', async () => { + await time.increase(ONE_DAY) + rentalOffer.borrower = borrower.address + rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` + rentalOffer.deadline = Number(await time.latest()) + ONE_DAY + rentalOffer.feeAmountPerSecond = toWei('0') + await marketplace.connect(lender).createRentalOffer(rentalOffer) + + const blockTimestamp = await time.latest() + const expirationDate = Number(blockTimestamp) + duration + 1 + await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) + .to.emit(marketplace, 'RentalStarted') + .withArgs(rentalOffer.lender, rentalOffer.nonce, borrower.address, expirationDate) + }) + it('Should accept a rental offer if token has a different registry', async () => { + await marketplaceRoyalties + .connect(operator) + .setRolesRegistry(await mockERC721.getAddress(), await rolesRegistry.getAddress()) + const blockTimestamp = await time.latest() + const expirationDate = Number(blockTimestamp) + duration + 1 + await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) + .to.emit(marketplace, 'RentalStarted') + .withArgs(rentalOffer.lender, rentalOffer.nonce, borrower.address, expirationDate) + }) + it('Should accept a rental offer more than once', async () => { + const rentalExpirationDate1 = Number(await time.latest()) + duration + 1 + + await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) + .to.emit(marketplace, 'RentalStarted') + .withArgs(rentalOffer.lender, rentalOffer.nonce, borrower.address, rentalExpirationDate1) + + await ethers.provider.send('evm_increaseTime', [duration + 1]) + await mockERC20.mint(borrower.address, totalFeeAmount.toString()) + await mockERC20.connect(borrower).approve(await marketplace.getAddress(), totalFeeAmount.toString()) + + await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) + .to.emit(marketplace, 'RentalStarted') + .withArgs(rentalOffer.lender, rentalOffer.nonce, borrower.address, rentalExpirationDate1 + duration + 3) + }) + it('Should accept a rental offer by anyone if borrower is the zero address', async () => { + await time.increase(ONE_DAY) + rentalOffer.borrower = ethers.ZeroAddress + rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` + rentalOffer.deadline = Number(await time.latest()) + ONE_DAY + await marketplace.connect(lender).createRentalOffer(rentalOffer) + + const blockTimestamp = Number(await time.latest()) + await mockERC20.mint(notOperator.address, totalFeeAmount.toString()) + await mockERC20.connect(notOperator).approve(await marketplace.getAddress(), totalFeeAmount.toString()) + await expect(marketplace.connect(notOperator).acceptRentalOffer(rentalOffer, duration)) + .to.emit(marketplace, 'RentalStarted') + .withArgs(rentalOffer.lender, rentalOffer.nonce, notOperator.address, blockTimestamp + duration + 3) + }) + it('Should accept a rental offer if duration is greater or equal minDuration', async () => { + await time.increase(ONE_DAY) + rentalOffer.minDuration = duration / 2 + rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` + rentalOffer.deadline = Number(await time.latest()) + ONE_DAY + await marketplace.connect(lender).createRentalOffer(rentalOffer) + await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.emit( + marketplace, + 'RentalStarted', + ) + }) + it('Should accept rental offer if marketplace fee is zero', async () => { + await marketplaceRoyalties + .connect(operator) + .setMarketplaceFeeForCollection(await mockERC721.getAddress(), 0, true) + await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.emit( + marketplace, + 'RentalStarted', + ) + }) + it('Should NOT accept a rental offer if duration is less than minDuration', async () => { + await time.increase(ONE_DAY) + rentalOffer.minDuration = duration + rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` + rentalOffer.deadline = Number(await time.latest()) + ONE_DAY + await marketplace.connect(lender).createRentalOffer(rentalOffer) + await expect( + marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration / 2), + ).to.be.revertedWith('OriumNftMarketplace: Duration is less than the offer minimum duration') + }) + it('Should NOT accept a rental offer if contract is paused', async () => { + await marketplace.connect(operator).pause() + await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.be.revertedWith( + 'Pausable: paused', + ) + }) + it('Should NOT accept a rental offer if caller is not the borrower', async () => { + await time.increase(ONE_DAY) + rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` + rentalOffer.borrower = borrower.address + rentalOffer.deadline = Number(await time.latest()) + ONE_DAY + await marketplace.connect(lender).createRentalOffer(rentalOffer) + await mockERC20.mint(notOperator.address, totalFeeAmount.toString()) + await expect( + marketplace.connect(notOperator).acceptRentalOffer(rentalOffer, duration), + ).to.be.revertedWith('OriumNftMarketplace: 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 = await time.latest() + const timeToMove = rentalOffer.deadline - Number(blockTimestamp) + 1 + await ethers.provider.send('evm_increaseTime', [timeToMove]) + + await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.be.revertedWith( + 'OriumNftMarketplace: 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( + 'OriumNftMarketplace: 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 time.latest()) + 1 + await expect( + marketplace.connect(borrower).acceptRentalOffer(rentalOffer, maxDuration), + ).to.be.revertedWith('OriumNftMarketplace: expiration date is greater than offer deadline') + }) + describe('Fees', async function () { + const feeAmountPerSecond = toWei('1') + const feeAmount = feeAmountPerSecond * BigInt(duration) + + beforeEach(async () => { + await time.increase(ONE_DAY) + rentalOffer.feeAmountPerSecond = feeAmountPerSecond + rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` + rentalOffer.deadline = Number(await time.latest()) + ONE_DAY + 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 = await time.latest() + const expirationDate = Number(blockTimestamp) + duration + 1 + await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) + .to.emit(marketplace, 'RentalStarted') + .withArgs(rentalOffer.lender, rentalOffer.nonce, borrower.address, expirationDate) + .to.emit(mockERC20, 'Transfer') + }) + it('Should accept a rental offer if marketplace fee is 0', async () => { + await marketplaceRoyalties + .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 marketplaceRoyalties + .connect(creator) + .setRoyaltyInfo(creator.address, 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( + 'OriumNftMarketplace: 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( + 'OriumNftMarketplace: 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( + 'OriumNftMarketplace: 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( + 'OriumNftMarketplace: This offer has an ongoing rental', + ) + }) + }) + }) + }) + }) + }) + 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', + ) + }) + }) + }) + }) + }) +}) From 4428673a17657ff4f8ac1f65bac4e5d3bf4fe915 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Fri, 19 Apr 2024 15:52:49 -0300 Subject: [PATCH 02/11] ON-814: fix rebase --- test/OriumNftMarketplace.test.ts | 484 ------------------------------- 1 file changed, 484 deletions(-) delete mode 100644 test/OriumNftMarketplace.test.ts diff --git a/test/OriumNftMarketplace.test.ts b/test/OriumNftMarketplace.test.ts deleted file mode 100644 index 6b9149d..0000000 --- a/test/OriumNftMarketplace.test.ts +++ /dev/null @@ -1,484 +0,0 @@ -/* eslint-disable no-unexpected-multiline */ -import { ethers } from 'hardhat' -import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers' -import { expect } from 'chai' -import { toWei } from '../utils/bignumber' -import { RentalOffer, RoyaltyInfo } from '../utils/types' -import { AddressZero, EMPTY_BYTES, ONE_DAY, ONE_HOUR, 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 creatorTreasury: Awaited> - let lender: Awaited> - let borrower: Awaited> - - // Values to be used across tests - const maxDeadline = THREE_MONTHS - const tokenId = 1 - const duration = ONE_HOUR - - 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, 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 () => { - await marketplaceRoyalties - .connect(operator) - .setRoyaltyInfo(creator.address, await mockERC721.getAddress(), 0, AddressZero) - - const royaltyInfo: RoyaltyInfo = { - creator: creator.address, - royaltyPercentageInWei: toWei('10'), - treasury: creatorTreasury.address, - } - - await marketplaceRoyalties - .connect(creator) - .setRoyaltyInfo( - creator.address, - await mockERC721.getAddress(), - royaltyInfo.royaltyPercentageInWei, - royaltyInfo.treasury, - ) - - const blockTimestamp = Number(await time.latest()) - - 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 time.latest()) + 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 time.latest()) - 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( - 'OriumNftMarketplace: 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('When Rental Offer is created', async () => { - let totalFeeAmount: bigint - beforeEach(async () => { - totalFeeAmount = rentalOffer.feeAmountPerSecond * BigInt(duration) - await marketplace.connect(lender).createRentalOffer(rentalOffer) - await mockERC20.mint(borrower.address, totalFeeAmount.toString()) - await mockERC20.connect(borrower).approve(await marketplace.getAddress(), totalFeeAmount.toString()) - await mockERC721.connect(lender).setApprovalForAll(await rolesRegistry.getAddress(), true) - }) - describe('Accept Rental Offer', async () => { - it('Should accept a public rental offer', async () => { - const blockTimestamp = await time.latest() - const expirationDate = Number(blockTimestamp) + duration + 1 - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs(rentalOffer.lender, rentalOffer.nonce, borrower.address, expirationDate) - }) - it('Should accept a private rental offer', async () => { - await time.increase(ONE_DAY) - rentalOffer.borrower = borrower.address - rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` - rentalOffer.deadline = Number(await time.latest()) + ONE_DAY - rentalOffer.feeAmountPerSecond = toWei('0') - await marketplace.connect(lender).createRentalOffer(rentalOffer) - - const blockTimestamp = await time.latest() - const expirationDate = Number(blockTimestamp) + duration + 1 - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs(rentalOffer.lender, rentalOffer.nonce, borrower.address, expirationDate) - }) - it('Should accept a rental offer if token has a different registry', async () => { - await marketplaceRoyalties - .connect(operator) - .setRolesRegistry(await mockERC721.getAddress(), await rolesRegistry.getAddress()) - const blockTimestamp = await time.latest() - const expirationDate = Number(blockTimestamp) + duration + 1 - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs(rentalOffer.lender, rentalOffer.nonce, borrower.address, expirationDate) - }) - it('Should accept a rental offer more than once', async () => { - const rentalExpirationDate1 = Number(await time.latest()) + duration + 1 - - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs(rentalOffer.lender, rentalOffer.nonce, borrower.address, rentalExpirationDate1) - - await ethers.provider.send('evm_increaseTime', [duration + 1]) - await mockERC20.mint(borrower.address, totalFeeAmount.toString()) - await mockERC20.connect(borrower).approve(await marketplace.getAddress(), totalFeeAmount.toString()) - - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs(rentalOffer.lender, rentalOffer.nonce, borrower.address, rentalExpirationDate1 + duration + 3) - }) - it('Should accept a rental offer by anyone if borrower is the zero address', async () => { - await time.increase(ONE_DAY) - rentalOffer.borrower = ethers.ZeroAddress - rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` - rentalOffer.deadline = Number(await time.latest()) + ONE_DAY - await marketplace.connect(lender).createRentalOffer(rentalOffer) - - const blockTimestamp = Number(await time.latest()) - await mockERC20.mint(notOperator.address, totalFeeAmount.toString()) - await mockERC20.connect(notOperator).approve(await marketplace.getAddress(), totalFeeAmount.toString()) - await expect(marketplace.connect(notOperator).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs(rentalOffer.lender, rentalOffer.nonce, notOperator.address, blockTimestamp + duration + 3) - }) - it('Should accept a rental offer if duration is greater or equal minDuration', async () => { - await time.increase(ONE_DAY) - rentalOffer.minDuration = duration / 2 - rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` - rentalOffer.deadline = Number(await time.latest()) + ONE_DAY - await marketplace.connect(lender).createRentalOffer(rentalOffer) - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.emit( - marketplace, - 'RentalStarted', - ) - }) - it('Should accept rental offer if marketplace fee is zero', async () => { - await marketplaceRoyalties - .connect(operator) - .setMarketplaceFeeForCollection(await mockERC721.getAddress(), 0, true) - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.emit( - marketplace, - 'RentalStarted', - ) - }) - it('Should NOT accept a rental offer if duration is less than minDuration', async () => { - await time.increase(ONE_DAY) - rentalOffer.minDuration = duration - rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` - rentalOffer.deadline = Number(await time.latest()) + ONE_DAY - await marketplace.connect(lender).createRentalOffer(rentalOffer) - await expect( - marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration / 2), - ).to.be.revertedWith('OriumNftMarketplace: Duration is less than the offer minimum duration') - }) - it('Should NOT accept a rental offer if contract is paused', async () => { - await marketplace.connect(operator).pause() - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.be.revertedWith( - 'Pausable: paused', - ) - }) - it('Should NOT accept a rental offer if caller is not the borrower', async () => { - await time.increase(ONE_DAY) - rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` - rentalOffer.borrower = borrower.address - rentalOffer.deadline = Number(await time.latest()) + ONE_DAY - await marketplace.connect(lender).createRentalOffer(rentalOffer) - await mockERC20.mint(notOperator.address, totalFeeAmount.toString()) - await expect( - marketplace.connect(notOperator).acceptRentalOffer(rentalOffer, duration), - ).to.be.revertedWith('OriumNftMarketplace: 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 = await time.latest() - const timeToMove = rentalOffer.deadline - Number(blockTimestamp) + 1 - await ethers.provider.send('evm_increaseTime', [timeToMove]) - - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)).to.be.revertedWith( - 'OriumNftMarketplace: 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( - 'OriumNftMarketplace: 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 time.latest()) + 1 - await expect( - marketplace.connect(borrower).acceptRentalOffer(rentalOffer, maxDuration), - ).to.be.revertedWith('OriumNftMarketplace: expiration date is greater than offer deadline') - }) - describe('Fees', async function () { - const feeAmountPerSecond = toWei('1') - const feeAmount = feeAmountPerSecond * BigInt(duration) - - beforeEach(async () => { - await time.increase(ONE_DAY) - rentalOffer.feeAmountPerSecond = feeAmountPerSecond - rentalOffer.nonce = `0x${randomBytes(32).toString('hex')}` - rentalOffer.deadline = Number(await time.latest()) + ONE_DAY - 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 = await time.latest() - const expirationDate = Number(blockTimestamp) + duration + 1 - await expect(marketplace.connect(borrower).acceptRentalOffer(rentalOffer, duration)) - .to.emit(marketplace, 'RentalStarted') - .withArgs(rentalOffer.lender, rentalOffer.nonce, borrower.address, expirationDate) - .to.emit(mockERC20, 'Transfer') - }) - it('Should accept a rental offer if marketplace fee is 0', async () => { - await marketplaceRoyalties - .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 marketplaceRoyalties - .connect(creator) - .setRoyaltyInfo(creator.address, 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( - 'OriumNftMarketplace: 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( - 'OriumNftMarketplace: 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( - 'OriumNftMarketplace: 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( - 'OriumNftMarketplace: This offer has an ongoing rental', - ) - }) - }) - }) - }) - }) - }) - 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', - ) - }) - }) - }) - }) - }) -}) From fb08fbb94bf9880f60649e07b959028c30cbe1fb Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Fri, 19 Apr 2024 16:37:10 -0300 Subject: [PATCH 03/11] ON-814: End Rental and Cancel Rental Offer --- contracts/NftRentalMarketplace.sol | 46 ++++ .../libraries/LibNftRentalMarketplace.sol | 50 ++++- .../libraries/LibOriumNftMarketplace.sol | 201 ------------------ contracts/mocks/NftRolesRegistryVault.sol | 4 + test/NftRentalMarketplace.test.ts | 110 +++++++++- 5 files changed, 207 insertions(+), 204 deletions(-) delete mode 100644 contracts/libraries/LibOriumNftMarketplace.sol diff --git a/contracts/NftRentalMarketplace.sol b/contracts/NftRentalMarketplace.sol index 6563bc5..8429c66 100644 --- a/contracts/NftRentalMarketplace.sol +++ b/contracts/NftRentalMarketplace.sol @@ -69,6 +69,18 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr */ event RentalStarted(address indexed lender, uint256 indexed nonce, address indexed borrower, uint64 expirationDate); + /** + * @param lender The address of the lender + * @param nonce The nonce of the rental offer + */ + event RentalOfferCancelled(address indexed lender, uint256 indexed nonce); + + /** + * @param lender The address of the lender + * @param nonce The nonce of the rental offer + */ + event RentalEnded(address indexed lender, uint256 indexed nonce); + /** ######### Initializer ########### **/ /** * @notice Initializes the contract. @@ -172,6 +184,40 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr emit RentalStarted(_offer.lender, _offer.nonce, msg.sender, _expirationDate); } + /** + * @notice Cancels a rental offer. + * @param _offer The rental offer struct. It should be the same as the one used to create the offer. + */ + + function cancelRentalOffer(RentalOffer calldata _offer) external whenNotPaused { + bytes32 _offerHash = LibNftRentalMarketplace.hashRentalOffer(_offer); + LibNftRentalMarketplace.validateCancelRentalOfferParams( + isCreated[_offerHash], + _offer.lender, + nonceDeadline[_offer.lender][_offer.nonce] + ); + + nonceDeadline[msg.sender][_offer.nonce] = uint64(block.timestamp); + emit RentalOfferCancelled(_offer.lender, _offer.nonce); + } + + /** + * @notice Ends the rental prematurely. + * @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 calldata _offer) external whenNotPaused { + bytes32 _offerHash = LibNftRentalMarketplace.hashRentalOffer(_offer); + Rental storage _rental = rentals[_offerHash]; + + LibNftRentalMarketplace.validateEndRentalParams(isCreated[_offerHash], _rental.borrower, _rental.expirationDate); + LibNftRentalMarketplace.revokeRoles(oriumMarketplaceRoyalties, _offer.tokenAddress, _offer.tokenId, _offer.roles); + + _rental.expirationDate = uint64(block.timestamp); + emit RentalEnded(_offer.lender, _offer.nonce); + } + /** ============================ Core Functions ================================== **/ /** ######### Setters ########### **/ diff --git a/contracts/libraries/LibNftRentalMarketplace.sol b/contracts/libraries/LibNftRentalMarketplace.sol index d5bf65d..d90b94d 100644 --- a/contracts/libraries/LibNftRentalMarketplace.sol +++ b/contracts/libraries/LibNftRentalMarketplace.sol @@ -38,7 +38,7 @@ library LibNftRentalMarketplace { * @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) { + function hashRentalOffer(RentalOffer memory _offer) public pure returns (bytes32) { return keccak256(abi.encode(_offer)); } @@ -222,4 +222,52 @@ library LibNftRentalMarketplace { ); } } + + /** + * @notice Validates the cancel rental offer params. + * @dev This function is used to validate the cancel rental offer params. + * @param _isCreated The offer is created + * @param _lender The lender address + * @param _nonceDeadline The nonce deadline + */ + function validateCancelRentalOfferParams(bool _isCreated, address _lender, uint256 _nonceDeadline) external view { + require(_isCreated, 'NftRentalMarketplace: Offer not created'); + require(msg.sender == _lender, 'NftRentalMarketplace: Only lender can cancel a rental offer'); + require(_nonceDeadline > block.timestamp, 'NftRentalMarketplace: Nonce expired or not used yet'); + } + + /** + * @notice Validates the end rental params. + * @dev This function is used to validate the end rental params. + * @param _isCreated The offer is created + * @param _borrower The borrower address + * @param _expirationDate The expiration date + */ + function validateEndRentalParams(bool _isCreated, address _borrower, uint64 _expirationDate) external view { + require(_isCreated, 'NftRentalMarketplace: Offer not created'); + require(msg.sender == _borrower, 'NftRentalMarketplace: Only borrower can end a rental'); + require(_expirationDate > block.timestamp, 'NftRentalMarketplace: There are no active Rentals'); + } + + /** + * @notice Revokes roles for the same NFT. + * @dev This function is used to batch revoke roles for the same NFT. + * @param _oriumMarketplaceRoyalties The Orium marketplace royalties contract address. + * @param _tokenAddress The token address. + * @param _tokenId The token id. + * @param _roleIds The role ids. + */ + function revokeRoles( + address _oriumMarketplaceRoyalties, + address _tokenAddress, + uint256 _tokenId, + bytes32[] calldata _roleIds + ) external { + address _rolesRegsitry = IOriumMarketplaceRoyalties(_oriumMarketplaceRoyalties).nftRolesRegistryOf( + _tokenAddress + ); + for (uint256 i = 0; i < _roleIds.length; i++) { + IERC7432(_rolesRegsitry).revokeRole(_tokenAddress, _tokenId, _roleIds[i]); + } + } } diff --git a/contracts/libraries/LibOriumNftMarketplace.sol b/contracts/libraries/LibOriumNftMarketplace.sol deleted file mode 100644 index 36e81cd..0000000 --- a/contracts/libraries/LibOriumNftMarketplace.sol +++ /dev/null @@ -1,201 +0,0 @@ -// SPDX-License-Identifier: CC0-1.0 - -pragma solidity 0.8.9; - -import { IERC7589 } from '../interfaces/IERC7589.sol'; -import { IOriumMarketplaceRoyalties } from '../interfaces/IOriumMarketplaceRoyalties.sol'; -import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; -import { IERC7432 } from '../interfaces/IERC7432.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; -} - -/// @dev Rental info. -struct Rental { - address borrower; - uint64 expirationDate; -} - -library LibOriumNftMarketplace { - /// @dev 100 ether is 100% - uint256 public constant MAX_PERCENTAGE = 100 ether; - - /** - * @notice Gets the rental offer hash. - * @dev This function is used to hash the rental offer struct with retrocompatibility. - * is only used for reading the hash from the storage. - * @param _offer The rental offer struct to be hashed. - */ - function hashRentalOffer(RentalOffer memory _offer) external pure returns (bytes32) { - return keccak256(abi.encode(_offer)); - } - - /** - * @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) public pure returns (uint256) { - return (_amount * _percentage) / MAX_PERCENTAGE; - } - - /** - * @notice Validates the rental offer. - * @param _offer The rental offer struct to be validated. - */ - function validateCreateRentalOfferParams( - address _oriumMarketplaceRoyalties, - RentalOffer memory _offer, - uint64 _nonceDeadline - ) external view { - 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'); - } - - /** - * @notice Transfers the fees. - * @dev The fee token address should be approved before calling this function. - * @param _feeTokenAddress The fee token address. - * @param _marketplaceTreasuryAddress The marketplace treasury address. - * @param _lenderAddress The lender address. - * @param _royaltiesAddress The Orium marketplace royalties contract address. - * @param _tokenAddress The token address. - * @param _feeAmountPerSecond The fee amount per second. - * @param _duration The duration of the rental. - */ - function transferFees( - address _feeTokenAddress, - address _marketplaceTreasuryAddress, - address _lenderAddress, - address _royaltiesAddress, - address _tokenAddress, - uint256 _feeAmountPerSecond, - uint64 _duration - ) external { - uint256 _totalAmount = _feeAmountPerSecond * _duration; - if (_totalAmount == 0) return; - - IOriumMarketplaceRoyalties _royalties = IOriumMarketplaceRoyalties(_royaltiesAddress); - uint256 _marketplaceFeePercentageInWei = _royalties.marketplaceFeeOf(_tokenAddress); - IOriumMarketplaceRoyalties.RoyaltyInfo memory _royaltyInfo = _royalties.royaltyInfoOf(_tokenAddress); - - uint256 _marketplaceAmount = getAmountFromPercentage(_totalAmount, _marketplaceFeePercentageInWei); - uint256 _royaltyAmount = getAmountFromPercentage(_totalAmount, _royaltyInfo.royaltyPercentageInWei); - uint256 _lenderAmount = _totalAmount - _royaltyAmount - _marketplaceAmount; - - _transferAmount(_feeTokenAddress, msg.sender, _marketplaceTreasuryAddress, _marketplaceAmount); - _transferAmount(_feeTokenAddress, msg.sender, _royaltyInfo.treasury, _royaltyAmount); - _transferAmount(_feeTokenAddress, msg.sender, _lenderAddress, _lenderAmount); - } - - /** - * @notice Transfers an amount to a receipient. - * @dev This function is used to make an ERC20 transfer. - * @param _tokenAddress The token address. - * @param _from The sender address. - * @param _to The recipient address. - * @param _amount The amount to transfer. - */ - function _transferAmount(address _tokenAddress, address _from, address _to, uint256 _amount) internal { - if (_amount == 0) return; - require(IERC20(_tokenAddress).transferFrom(_from, _to, _amount), 'OriumNftMarketplace: Transfer failed'); - } - - function validateAcceptRentalOfferParams( - address _borrower, - uint64 _minDuration, - bool _isCreated, - uint64 _previousRentalExpirationDate, - uint64 _duration, - uint256 _nonceDeadline, - uint64 _expirationDate - ) external view { - require(_isCreated, 'OriumNftMarketplace: Offer not created'); - require( - _previousRentalExpirationDate <= block.timestamp, - 'OriumNftMarketplace: This offer has an ongoing rental' - ); - require(_duration >= _minDuration, 'OriumNftMarketplace: Duration is less than the offer minimum duration'); - require( - _nonceDeadline > _expirationDate, - 'OriumNftMarketplace: expiration date is greater than offer deadline' - ); - require( - address(0) == _borrower || msg.sender == _borrower, - 'OriumNftMarketplace: Sender is not allowed to rent this NFT' - ); - } - - /** - * @notice Batch grants roles. - * @dev This function is used to batch grant roles. - * @param _oriumMarketplaceRoyalties The Orium marketplace royalties contract address. - * @param _tokenAddress The token address. - * @param _tokenId The token id. - * @param _recipient The recipient address. - * @param _expirationDate The expiration date. - * @param _roleIds The role ids. - * @param _data The data. - */ - function batchGrantRole( - address _oriumMarketplaceRoyalties, - address _tokenAddress, - uint256 _tokenId, - address _recipient, - uint64 _expirationDate, - bytes32[] calldata _roleIds, - bytes[] calldata _data - ) external { - address _rolesRegsitry = IOriumMarketplaceRoyalties(_oriumMarketplaceRoyalties).nftRolesRegistryOf( - _tokenAddress - ); - - for (uint256 i = 0; i < _roleIds.length; i++) { - IERC7432(_rolesRegsitry).grantRole( - IERC7432.Role({ - roleId: _roleIds[i], - tokenAddress: _tokenAddress, - tokenId: _tokenId, - recipient: _recipient, - expirationDate: _expirationDate, - revocable: false, - data: _data[i] - }) - ); - } - } -} diff --git a/contracts/mocks/NftRolesRegistryVault.sol b/contracts/mocks/NftRolesRegistryVault.sol index 63fa748..3ad3036 100644 --- a/contracts/mocks/NftRolesRegistryVault.sol +++ b/contracts/mocks/NftRolesRegistryVault.sol @@ -5,6 +5,7 @@ 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'; +import "hardhat/console.sol"; contract NftRolesRegistryVault is IERC7432, IERC7432VaultExtension { struct RoleData { @@ -62,6 +63,9 @@ contract NftRolesRegistryVault is IERC7432, IERC7432VaultExtension { address _recipient = roles[_tokenAddress][_tokenId][_roleId].recipient; address _caller = _getApprovedCaller(_tokenAddress, _tokenId, _recipient); + console.log("caller: %s", _caller); + console.log("recipient: %s", _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 diff --git a/test/NftRentalMarketplace.test.ts b/test/NftRentalMarketplace.test.ts index 9f12b46..cbae660 100644 --- a/test/NftRentalMarketplace.test.ts +++ b/test/NftRentalMarketplace.test.ts @@ -7,13 +7,19 @@ import { RentalOffer, RoyaltyInfo } from '../utils/types' import { AddressZero, EMPTY_BYTES, ONE_DAY, ONE_HOUR, THREE_MONTHS } from '../utils/constants' import { randomBytes } from 'crypto' import { USER_ROLE } from '../utils/roles' -import { IERC7432, MockERC20, MockERC721, OriumMarketplaceRoyalties, NftRentalMarketplace } from '../typechain-types' +import { + MockERC20, + MockERC721, + OriumMarketplaceRoyalties, + NftRentalMarketplace, + NftRolesRegistryVault, +} from '../typechain-types' import { deployNftMarketplaceContracts } from './fixtures/NftRentalMarketplaceFixture' describe('NftRentalMarketplace', () => { let marketplace: NftRentalMarketplace let marketplaceRoyalties: OriumMarketplaceRoyalties - let rolesRegistry: IERC7432 + let rolesRegistry: NftRolesRegistryVault let mockERC721: MockERC721 let mockERC20: MockERC20 @@ -474,6 +480,106 @@ describe('NftRentalMarketplace', () => { }) }) }) + + describe('Cancel Rental Offer', async () => { + it('Should cancel a rental offer and releaseTokens from rolesRegistry', async () => { + await expect(marketplace.connect(lender).cancelRentalOffer(rentalOffer)) + .to.emit(marketplace, 'RentalOfferCancelled') + .withArgs(rentalOffer.lender, rentalOffer.nonce) + }) + it('Should NOT cancel a rental offer if contract is paused', async () => { + await marketplace.connect(operator).pause() + await expect(marketplace.connect(borrower).cancelRentalOffer(rentalOffer)).to.be.revertedWith( + 'Pausable: paused', + ) + }) + it('Should NOT cancel a rental offer if nonce not used yet by caller', async () => { + await expect(marketplace.connect(notOperator).cancelRentalOffer(rentalOffer)).to.be.revertedWith( + 'NftRentalMarketplace: Only lender can cancel a rental offer', + ) + }) + it("Should NOT cancel a rental offer after deadline's expiration", async () => { + // move forward in time to expire the offer + const blockTimestamp = (await ethers.provider.getBlock('latest'))?.timestamp + const timeToMove = rentalOffer.deadline - Number(blockTimestamp) + 1 + await ethers.provider.send('evm_increaseTime', [timeToMove]) + + await expect(marketplace.connect(lender).cancelRentalOffer(rentalOffer)).to.be.revertedWith( + 'NftRentalMarketplace: Nonce expired or not used yet', + ) + }) + it("Should NOT cancel a rental offer if it's not created", async () => { + await expect( + marketplace + .connect(lender) + .cancelRentalOffer({ ...rentalOffer, nonce: `0x${randomBytes(32).toString('hex')}` }), + ).to.be.revertedWith('NftRentalMarketplace: Offer not created') + }) + }) + + describe('When Rental Offer is accepted', async () => { + beforeEach(async () => { + 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.lender, rentalOffer.nonce) + }) + it('Should NOT end a rental if contract is paused', async () => { + await marketplace.connect(operator).pause() + await expect(marketplace.connect(lender).endRental(rentalOffer)).to.be.revertedWith('Pausable: paused') + }) + it('Should NOT end a rental by the lender', async () => { + await expect(marketplace.connect(lender).endRental(rentalOffer)).to.be.revertedWith( + 'NftRentalMarketplace: 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( + 'NftRentalMarketplace: 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('NftRentalMarketplace: Offer not created') + }) + it('Should NOT end a rental if rental is expired', async () => { + // move foward in time to expire the offer + const blockTimestamp = (await ethers.provider.getBlock('latest'))?.timestamp + const timeToMove = rentalOffer.deadline - Number(blockTimestamp) + 1 + await ethers.provider.send('evm_increaseTime', [timeToMove]) + + await expect(marketplace.connect(borrower).endRental(rentalOffer)).to.be.revertedWith( + 'NftRentalMarketplace: There are no active Rentals', + ) + }) + it('Should NOT end a rental if the role was revoked by borrower directly in registry', async () => { + await rolesRegistry + .connect(borrower) + .setRoleApprovalForAll(await mockERC721.getAddress(), await marketplace.getAddress(), true) + await rolesRegistry + .connect(borrower) + .revokeRole(rentalOffer.tokenAddress, rentalOffer.tokenId, rentalOffer.roles[0]) + await expect(marketplace.connect(borrower).endRental(rentalOffer)).to.be.revertedWith( + 'SftRolesRegistry: grantee mismatch', + ) + }) + it('Should NOT end rental twice', async () => { + await marketplace.connect(borrower).endRental(rentalOffer) + await expect(marketplace.connect(borrower).endRental(rentalOffer)).to.be.revertedWith( + 'NftRentalMarketplace: There are no active Rentals', + ) + }) + }) + }) }) }) }) From b454e2bf9943c9a0213c137f7b743c439d75e840 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Fri, 19 Apr 2024 17:11:04 -0300 Subject: [PATCH 04/11] ON-814: update mock --- contracts/mocks/NftRolesRegistryVault.sol | 4 ---- 1 file changed, 4 deletions(-) diff --git a/contracts/mocks/NftRolesRegistryVault.sol b/contracts/mocks/NftRolesRegistryVault.sol index 3ad3036..63fa748 100644 --- a/contracts/mocks/NftRolesRegistryVault.sol +++ b/contracts/mocks/NftRolesRegistryVault.sol @@ -5,7 +5,6 @@ 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'; -import "hardhat/console.sol"; contract NftRolesRegistryVault is IERC7432, IERC7432VaultExtension { struct RoleData { @@ -63,9 +62,6 @@ contract NftRolesRegistryVault is IERC7432, IERC7432VaultExtension { address _recipient = roles[_tokenAddress][_tokenId][_roleId].recipient; address _caller = _getApprovedCaller(_tokenAddress, _tokenId, _recipient); - console.log("caller: %s", _caller); - console.log("recipient: %s", _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 From 80909a462b07cc97288f9df2250b164710486a16 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Fri, 19 Apr 2024 17:21:22 -0300 Subject: [PATCH 05/11] ON-812: prettier --- contracts/NftRentalMarketplace.sol | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/contracts/NftRentalMarketplace.sol b/contracts/NftRentalMarketplace.sol index 8429c66..0d595b8 100644 --- a/contracts/NftRentalMarketplace.sol +++ b/contracts/NftRentalMarketplace.sol @@ -211,8 +211,17 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr bytes32 _offerHash = LibNftRentalMarketplace.hashRentalOffer(_offer); Rental storage _rental = rentals[_offerHash]; - LibNftRentalMarketplace.validateEndRentalParams(isCreated[_offerHash], _rental.borrower, _rental.expirationDate); - LibNftRentalMarketplace.revokeRoles(oriumMarketplaceRoyalties, _offer.tokenAddress, _offer.tokenId, _offer.roles); + LibNftRentalMarketplace.validateEndRentalParams( + isCreated[_offerHash], + _rental.borrower, + _rental.expirationDate + ); + LibNftRentalMarketplace.revokeRoles( + oriumMarketplaceRoyalties, + _offer.tokenAddress, + _offer.tokenId, + _offer.roles + ); _rental.expirationDate = uint64(block.timestamp); emit RentalEnded(_offer.lender, _offer.nonce); From 38d1f8b0f4a3396c36980ec364a42404c8950610 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Fri, 19 Apr 2024 17:25:48 -0300 Subject: [PATCH 06/11] ON-814: pr fixes --- contracts/libraries/LibNftRentalMarketplace.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/libraries/LibNftRentalMarketplace.sol b/contracts/libraries/LibNftRentalMarketplace.sol index d90b94d..0117f79 100644 --- a/contracts/libraries/LibNftRentalMarketplace.sol +++ b/contracts/libraries/LibNftRentalMarketplace.sol @@ -38,7 +38,7 @@ library LibNftRentalMarketplace { * @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) public pure returns (bytes32) { + function hashRentalOffer(RentalOffer memory _offer) external pure returns (bytes32) { return keccak256(abi.encode(_offer)); } From 685e02643df5aad56d1ac25afc18894fd902131a Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Fri, 19 Apr 2024 17:30:23 -0300 Subject: [PATCH 07/11] ON-814: update tests --- test/NftRentalMarketplace.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/test/NftRentalMarketplace.test.ts b/test/NftRentalMarketplace.test.ts index cbae660..94850d1 100644 --- a/test/NftRentalMarketplace.test.ts +++ b/test/NftRentalMarketplace.test.ts @@ -561,17 +561,6 @@ describe('NftRentalMarketplace', () => { 'NftRentalMarketplace: There are no active Rentals', ) }) - it('Should NOT end a rental if the role was revoked by borrower directly in registry', async () => { - await rolesRegistry - .connect(borrower) - .setRoleApprovalForAll(await mockERC721.getAddress(), await marketplace.getAddress(), true) - await rolesRegistry - .connect(borrower) - .revokeRole(rentalOffer.tokenAddress, rentalOffer.tokenId, rentalOffer.roles[0]) - await expect(marketplace.connect(borrower).endRental(rentalOffer)).to.be.revertedWith( - 'SftRolesRegistry: grantee mismatch', - ) - }) it('Should NOT end rental twice', async () => { await marketplace.connect(borrower).endRental(rentalOffer) await expect(marketplace.connect(borrower).endRental(rentalOffer)).to.be.revertedWith( From 786d655ce24d8f259014a121dc940a2472af4bab Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Tue, 23 Apr 2024 12:43:19 -0300 Subject: [PATCH 08/11] ON-814: PR fixes --- contracts/NftRentalMarketplace.sol | 32 ++++++++++++++++++++++++++++++ test/NftRentalMarketplace.test.ts | 20 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/contracts/NftRentalMarketplace.sol b/contracts/NftRentalMarketplace.sol index 0d595b8..ebb175c 100644 --- a/contracts/NftRentalMarketplace.sol +++ b/contracts/NftRentalMarketplace.sol @@ -4,6 +4,8 @@ pragma solidity 0.8.9; import { IERC721 } from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import { IERC7432VaultExtension } from './interfaces/IERC7432VaultExtension.sol'; +import { ERC165Checker } from '@openzeppelin/contracts/utils/introspection/ERC165Checker.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'; @@ -16,6 +18,8 @@ import { LibNftRentalMarketplace, RentalOffer, Rental } from './libraries/LibNft * @author Orium Network Team - developers@orium.network */ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgradeable { + using ERC165Checker for address; + /** ######### Global Variables ########### **/ /// @dev oriumMarketplaceRoyalties stores the collection royalties and fees @@ -190,6 +194,34 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr */ function cancelRentalOffer(RentalOffer calldata _offer) external whenNotPaused { + _cancelRentalOffer(_offer); + } + + /** + * @notice Cancels a rental offer and withdraws the NFT. + * @dev Can only be called by the lender, and only withdraws the NFT if the rental has expired. + * @param _offer The rental offer struct. It should be the same as the one used to create the offer. + */ + + function cancelRentalOfferAndWithdraw(RentalOffer calldata _offer) external whenNotPaused { + _cancelRentalOffer(_offer); + + address _rolesRegistry = IOriumMarketplaceRoyalties(oriumMarketplaceRoyalties).nftRolesRegistryOf( + _offer.tokenAddress + ); + require( + _rolesRegistry.supportsERC165InterfaceUnchecked(type(IERC7432VaultExtension).interfaceId), + 'NftRentalMarketplace: roles registry does not support IERC7432VaultExtension' + ); + IERC7432VaultExtension(_rolesRegistry).withdraw(_offer.tokenAddress, _offer.tokenId); + } + + /** + * @notice Cancels a rental offer. + * @dev Internal function to cancel a rental offer. + * @param _offer The rental offer struct. It should be the same as the one used to create the offer. + */ + function _cancelRentalOffer(RentalOffer calldata _offer) internal { bytes32 _offerHash = LibNftRentalMarketplace.hashRentalOffer(_offer); LibNftRentalMarketplace.validateCancelRentalOfferParams( isCreated[_offerHash], diff --git a/test/NftRentalMarketplace.test.ts b/test/NftRentalMarketplace.test.ts index 94850d1..1dc8079 100644 --- a/test/NftRentalMarketplace.test.ts +++ b/test/NftRentalMarketplace.test.ts @@ -482,7 +482,7 @@ describe('NftRentalMarketplace', () => { }) describe('Cancel Rental Offer', async () => { - it('Should cancel a rental offer and releaseTokens from rolesRegistry', async () => { + it('Should cancel a rental offer', async () => { await expect(marketplace.connect(lender).cancelRentalOffer(rentalOffer)) .to.emit(marketplace, 'RentalOfferCancelled') .withArgs(rentalOffer.lender, rentalOffer.nonce) @@ -568,6 +568,24 @@ describe('NftRentalMarketplace', () => { ) }) }) + + describe('Cancel Rental Offer', async function () { + it('Should cancel a rental offer and withdraw from rolesRegistry, if rental is not active', async () => { + await expect(marketplace.connect(lender).cancelRentalOfferAndWithdraw(rentalOffer)) + .to.emit(marketplace, 'RentalOfferCancelled') + .withArgs(rentalOffer.lender, rentalOffer.nonce) + .to.emit(rolesRegistry, 'Withdraw') + .withArgs(rentalOffer.lender, rentalOffer.tokenAddress, rentalOffer.tokenId) + }) + it('Should NOT cancel a rental offer and withdraw from rolesRegistry, if rolesRegistry does not support IERC7432VaultExtension', async () => { + await marketplaceRoyalties + .connect(operator) + .setRolesRegistry(await mockERC721.getAddress(), AddressZero) + await expect(marketplace.connect(lender).cancelRentalOfferAndWithdraw(rentalOffer)).to.be.revertedWith( + 'NftRentalMarketplace: roles registry does not support IERC7432VaultExtension', + ) + }) + }) }) }) }) From 959e877717df52e497eb9e9ef8705500dcfe2a16 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Tue, 23 Apr 2024 12:46:41 -0300 Subject: [PATCH 09/11] ON-814: reorg functions --- contracts/NftRentalMarketplace.sol | 36 ++++++++++++++++-------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/contracts/NftRentalMarketplace.sol b/contracts/NftRentalMarketplace.sol index ebb175c..47585c4 100644 --- a/contracts/NftRentalMarketplace.sol +++ b/contracts/NftRentalMarketplace.sol @@ -216,23 +216,6 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr IERC7432VaultExtension(_rolesRegistry).withdraw(_offer.tokenAddress, _offer.tokenId); } - /** - * @notice Cancels a rental offer. - * @dev Internal function to cancel a rental offer. - * @param _offer The rental offer struct. It should be the same as the one used to create the offer. - */ - function _cancelRentalOffer(RentalOffer calldata _offer) internal { - bytes32 _offerHash = LibNftRentalMarketplace.hashRentalOffer(_offer); - LibNftRentalMarketplace.validateCancelRentalOfferParams( - isCreated[_offerHash], - _offer.lender, - nonceDeadline[_offer.lender][_offer.nonce] - ); - - nonceDeadline[msg.sender][_offer.nonce] = uint64(block.timestamp); - emit RentalOfferCancelled(_offer.lender, _offer.nonce); - } - /** * @notice Ends the rental prematurely. * @dev Can only be called by the borrower. @@ -259,6 +242,25 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr emit RentalEnded(_offer.lender, _offer.nonce); } + /** ######### Internals ########### **/ + + /** + * @notice Cancels a rental offer. + * @dev Internal function to cancel a rental offer. + * @param _offer The rental offer struct. It should be the same as the one used to create the offer. + */ + function _cancelRentalOffer(RentalOffer calldata _offer) internal { + bytes32 _offerHash = LibNftRentalMarketplace.hashRentalOffer(_offer); + LibNftRentalMarketplace.validateCancelRentalOfferParams( + isCreated[_offerHash], + _offer.lender, + nonceDeadline[_offer.lender][_offer.nonce] + ); + + nonceDeadline[msg.sender][_offer.nonce] = uint64(block.timestamp); + emit RentalOfferCancelled(_offer.lender, _offer.nonce); + } + /** ============================ Core Functions ================================== **/ /** ######### Setters ########### **/ From 5a48c1654660516e8b37642465a58b55e5be491d Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Wed, 24 Apr 2024 11:14:21 -0300 Subject: [PATCH 10/11] ON-814: PR fixes --- contracts/NftRentalMarketplace.sol | 5 +++-- contracts/libraries/LibNftRentalMarketplace.sol | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/contracts/NftRentalMarketplace.sol b/contracts/NftRentalMarketplace.sol index 47585c4..464cfea 100644 --- a/contracts/NftRentalMarketplace.sol +++ b/contracts/NftRentalMarketplace.sol @@ -192,7 +192,6 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr * @notice Cancels a rental offer. * @param _offer The rental offer struct. It should be the same as the one used to create the offer. */ - function cancelRentalOffer(RentalOffer calldata _offer) external whenNotPaused { _cancelRentalOffer(_offer); } @@ -202,7 +201,6 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr * @dev Can only be called by the lender, and only withdraws the NFT if the rental has expired. * @param _offer The rental offer struct. It should be the same as the one used to create the offer. */ - function cancelRentalOfferAndWithdraw(RentalOffer calldata _offer) external whenNotPaused { _cancelRentalOffer(_offer); @@ -258,6 +256,9 @@ contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgr ); nonceDeadline[msg.sender][_offer.nonce] = uint64(block.timestamp); + for(uint256 i = 0; i < _offer.roles.length; i++) { + roleDeadline[_offer.roles[i]][_offer.tokenAddress][_offer.tokenId] = uint64(block.timestamp); + } emit RentalOfferCancelled(_offer.lender, _offer.nonce); } diff --git a/contracts/libraries/LibNftRentalMarketplace.sol b/contracts/libraries/LibNftRentalMarketplace.sol index 0117f79..9693189 100644 --- a/contracts/libraries/LibNftRentalMarketplace.sol +++ b/contracts/libraries/LibNftRentalMarketplace.sol @@ -226,7 +226,7 @@ library LibNftRentalMarketplace { /** * @notice Validates the cancel rental offer params. * @dev This function is used to validate the cancel rental offer params. - * @param _isCreated The offer is created + * @param _isCreated Whether the offer is created * @param _lender The lender address * @param _nonceDeadline The nonce deadline */ From 1c8eadfd72a78bb88274935c83ec098cd5611ded Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Wed, 24 Apr 2024 11:28:45 -0300 Subject: [PATCH 11/11] ON-814: Increase coverage --- test/NftRentalMarketplace.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/NftRentalMarketplace.test.ts b/test/NftRentalMarketplace.test.ts index 1dc8079..5704896 100644 --- a/test/NftRentalMarketplace.test.ts +++ b/test/NftRentalMarketplace.test.ts @@ -585,6 +585,12 @@ describe('NftRentalMarketplace', () => { 'NftRentalMarketplace: roles registry does not support IERC7432VaultExtension', ) }) + it('Should NOT cancel a rental offer and withdraw if contract is paused', async () => { + await marketplace.connect(operator).pause() + await expect(marketplace.connect(lender).cancelRentalOfferAndWithdraw(rentalOffer)).to.be.revertedWith( + 'Pausable: paused', + ) + }) }) }) })