Skip to content

Commit

Permalink
Merge pull request #44 from OriumNetwork/feature--ON-814
Browse files Browse the repository at this point in the history
ON-814: ERC721-Marketplace - Cancel Rental Offer & End Rental Prematurely
  • Loading branch information
karacurt authored Apr 24, 2024
2 parents 2f5f30d + 1c8eadf commit fcf7231
Show file tree
Hide file tree
Showing 3 changed files with 259 additions and 2 deletions.
90 changes: 90 additions & 0 deletions contracts/NftRentalMarketplace.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,6 +18,8 @@ import { LibNftRentalMarketplace, RentalOffer, Rental } from './libraries/LibNft
* @author Orium Network Team - [email protected]
*/
contract NftRentalMarketplace is Initializable, OwnableUpgradeable, PausableUpgradeable {
using ERC165Checker for address;

/** ######### Global Variables ########### **/

/// @dev oriumMarketplaceRoyalties stores the collection royalties and fees
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ########### **/
Expand Down
48 changes: 48 additions & 0 deletions contracts/libraries/LibNftRentalMarketplace.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}
}
123 changes: 121 additions & 2 deletions test/NftRentalMarketplace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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',
)
})
})
})
})
})
})
Expand Down

0 comments on commit fcf7231

Please sign in to comment.