diff --git a/contracts/NftRentalMarketplace.sol b/contracts/NftRentalMarketplace.sol index 6563bc5..464cfea 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 @@ -69,6 +73,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 +188,80 @@ 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 { + _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 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); + } + + /** ######### 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); + 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); + } + /** ============================ Core Functions ================================== **/ /** ######### Setters ########### **/ diff --git a/contracts/libraries/LibNftRentalMarketplace.sol b/contracts/libraries/LibNftRentalMarketplace.sol index d5bf65d..9693189 100644 --- a/contracts/libraries/LibNftRentalMarketplace.sol +++ b/contracts/libraries/LibNftRentalMarketplace.sol @@ -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 Whether 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/test/NftRentalMarketplace.test.ts b/test/NftRentalMarketplace.test.ts index 9f12b46..5704896 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,119 @@ describe('NftRentalMarketplace', () => { }) }) }) + + describe('Cancel Rental Offer', async () => { + it('Should cancel a rental offer', 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 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', + ) + }) + }) + + 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', + ) + }) + 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', + ) + }) + }) + }) }) }) })