Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ON-814: ERC721-Marketplace - Cancel Rental Offer & End Rental Prematurely #44

Merged
merged 11 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
ernanirst marked this conversation as resolved.
Show resolved Hide resolved
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');
ernanirst marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* @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 {
ernanirst marked this conversation as resolved.
Show resolved Hide resolved
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'
ernanirst marked this conversation as resolved.
Show resolved Hide resolved
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
Loading