diff --git a/src/RentalManager.sol b/src/RentalManager.sol new file mode 100644 index 0000000..c012ca3 --- /dev/null +++ b/src/RentalManager.sol @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.10; + +import {ERC721} from "solmate/tokens/ERC721.sol"; +import {IERC721TokenReceiver} from "./interfaces/IERC721TokenReceiver.sol"; + +/// @title RentalManager +/// @author 0xm00neth <0xm00neth@gmail.com> +/// @notice A Collateral-based ERC721 Token Rental Protocol +contract RentalManager { + /// -------------------------------------------- /// + /// ------------------- STRUCT ----------------- /// + /// -------------------------------------------- /// + + struct Rental { + // The address of the original owner + address lenderAddress; + // The address of the tempory borrower + address borrowerAddress; + // The collection of the NFT to lend + ERC721 nftCollection; + // Store if the NFT has been deposited + bool nftIsDeposited; + // Store if the borrower's required ETH has been deposited + bool ethIsDeposited; + // The the id of the NFT within the collection + uint256 nftId; + // The expiration time of the rental + uint256 dueDate; + // The amount of ETH the borrower must pay the lender in order to rent the NFT if returned on time + uint256 rentalPayment; + // The amount of additional ETH the lender requires as collateral + uint256 collateral; + // The amount of time the collateral will be linearly paid out over if the NFT isn't returned on time + uint256 collateralPayoutPeriod; + // The time when the rental contract officially begins + uint256 rentalStartTime; + // The amount of collateral collected by the lender + uint256 collectedCollateral; + } + + /// -------------------------------------------- /// + /// ------------------- STATE ------------------ /// + /// -------------------------------------------- /// + + uint256 private _rentalIdPointer; + + uint256 private _entered = 1; + + /// @notice rentals details + mapping(uint256 => Rental) public rentals; + + /// -------------------------------------------- /// + /// ------------------- EVENTS ----------------- /// + /// -------------------------------------------- /// + + event RentalStarted(uint256 rentalId); + + /// -------------------------------------------- /// + /// ------------------ ERRORS ------------------ /// + /// -------------------------------------------- /// + + error InvalidRentalId(); + error InsufficientValue(); + error Unauthorized(); + error InvalidState(); + error BadTimeBounds(); + error AlreadyDeposited(); + error NonTokenOwner(); + error Reentrant(); + + /// -------------------------------------------- /// + /// ----------------- MODIFIERS ---------------- /// + /// -------------------------------------------- /// + + modifier rentalExists(uint256 rentalId) { + if (rentalId >= _rentalIdPointer) revert InvalidRentalId(); + _; + } + + modifier nonReentrant() { + if (_entered == 2) revert Reentrant(); + _entered = 2; + _; + _entered = 1; + } + + /// -------------------------------------------- /// + /// -------------- EXTERNAL LOGIC -------------- /// + /// -------------------------------------------- /// + + /// @notice setup new rental for lender and borrwer + /// @param _lenderAddress the address of lender + /// @param _borrowerAddress the address of borrower + /// @param _nftAddress the address of nft + /// @param _nftId nft id + /// @param _dueDate rental due date + /// @param _rentalPayment rental fee amount + /// @param _collateral collateral amount + /// @param _collateralPayoutPeriod collateral payout period + function createRental( + address _lenderAddress, + address _borrowerAddress, + address _nftAddress, + uint256 _nftId, + uint256 _dueDate, + uint256 _rentalPayment, + uint256 _collateral, + uint256 _collateralPayoutPeriod + ) external { + // Require that the _lenderAddress owns the specified NFT + if (ERC721(_nftAddress).ownerOf(_nftId) != _lenderAddress) + revert NonTokenOwner(); + + // Require that the _borrowerAddress has more than _rentalPayment + _collateral + if (_borrowerAddress.balance < _rentalPayment + _collateral) + revert InsufficientValue(); + + // Require that the expiry is in the future + if (_dueDate < block.timestamp) revert BadTimeBounds(); + + uint256 rentalId = _rentalIdPointer++; + + rentals[rentalId] = Rental({ + lenderAddress: payable(_lenderAddress), + borrowerAddress: payable(_borrowerAddress), + nftCollection: ERC721(_nftAddress), + nftId: _nftId, + dueDate: _dueDate, + rentalPayment: _rentalPayment, + collateral: _collateral, + collateralPayoutPeriod: _collateralPayoutPeriod, + rentalStartTime: 0, + collectedCollateral: 0, + nftIsDeposited: false, + ethIsDeposited: false + }); + } + + /// @notice Lender must deposit the ERC721 token to enable lending + /// @notice First step after Rental Contract Construction + /// @param _rentalId rental id + function depositNft(uint256 _rentalId) external rentalExists(_rentalId) { + Rental storage rental = rentals[_rentalId]; + + // We don't accept double deposits + if (rental.nftIsDeposited) revert AlreadyDeposited(); + rental.nftIsDeposited = true; + + // The ERC721 Token Depositer must be the lender + if (msg.sender != rental.lenderAddress) revert Unauthorized(); + + // If the borrower has not deposited their required ETH yet, send the NFT to the contract + if (!rental.ethIsDeposited) { + rental.nftCollection.safeTransferFrom( + msg.sender, + address(this), + rental.nftId + ); + } else { + rental.nftCollection.safeTransferFrom( + msg.sender, + rental.borrowerAddress, + rental.nftId + ); + // Send lender the ETH rental payment from the contract (keeping collateral stored) + payable(rental.lenderAddress).transfer(rental.rentalPayment); + emit RentalStarted(_rentalId); + _beginRental(_rentalId); + } + } + + /// @notice Allows the borrow to post rent plus collateral + /// @notice Transfers the NFT to the borrower if the token has been deposited by the lender + /// @param _rentalId rental id + function depositEth(uint256 _rentalId) + external + payable + rentalExists(_rentalId) + { + Rental storage rental = rentals[_rentalId]; + + // We don't accept double deposits + if (rental.ethIsDeposited) revert AlreadyDeposited(); + rental.ethIsDeposited = true; + + // The ETH Depositer must be the borrower + if (msg.sender != rental.borrowerAddress) revert Unauthorized(); + + if (msg.value < rental.rentalPayment + rental.collateral) + revert InsufficientValue(); + + // If the borrower sent too much ETH, immediately refund them the extra ETH they sent + if (msg.value > rental.rentalPayment + rental.collateral) { + payable(msg.sender).transfer( + msg.value - (rental.rentalPayment + rental.collateral) + ); + } + + // If the lender has not deposited their nft, send the ETH to the contract + if (!rental.nftIsDeposited) { + // The msg.value is automatically sent to the contract + } else { + // If the lender has deposited their nft, send the rental payment eth to the lender + payable(rental.lenderAddress).transfer(rental.rentalPayment); + // Transfer the NFT from the contract to the borrower + rental.nftCollection.safeTransferFrom( + address(this), + rental.borrowerAddress, + rental.nftId + ); + emit RentalStarted(_rentalId); + _beginRental(_rentalId); + } + } + + /// @notice Allows the lender to withdraw an nft if the borrower doesn't deposit + /// @param _rentalId rental id + function withdrawNft(uint256 _rentalId) + external + rentalExists(_rentalId) + nonReentrant + { + Rental storage rental = rentals[_rentalId]; + + // Require that only the lender can withdraw the NFT + if (msg.sender != rental.lenderAddress) revert Unauthorized(); + + // Require that the NFT is in the contract and the ETH has not yet been deposited + if (!rental.nftIsDeposited || rental.ethIsDeposited) + revert InvalidState(); + + // Send the nft back to the lender + rental.nftCollection.safeTransferFrom( + address(this), + rental.lenderAddress, + rental.nftId + ); + } + + /// @notice Allows the borrower to withdraw eth if the lender doesn't deposit + /// @param _rentalId rental id + function withdrawEth(uint256 _rentalId) + external + rentalExists(_rentalId) + nonReentrant + { + Rental storage rental = rentals[_rentalId]; + + // Require that only the borrower can call this function + if (msg.sender != rental.borrowerAddress) revert Unauthorized(); + + // Require that the ETH has already been deposited and the NFT has not been + if (rental.nftIsDeposited || !rental.ethIsDeposited) + revert InvalidState(); + + // Have the contract send the eth back to the borrower + payable(rental.borrowerAddress).transfer( + rental.rentalPayment + rental.collateral + ); + } + + /// @notice Allows the Borrower to return the borrowed NFT + /// @param _rentalId rental id + function returnNft(uint256 _rentalId) + external + rentalExists(_rentalId) + nonReentrant + { + Rental storage rental = rentals[_rentalId]; + + // Return the NFT from the borrower to the lender + rental.nftCollection.safeTransferFrom( + msg.sender, + rental.lenderAddress, + rental.nftId + ); + + // Check if the NFT has been returned on time + if (block.timestamp <= rental.dueDate) { + // Return the collateral to the borrower + payable(rental.borrowerAddress).transfer(rental.collateral); + } + // Check if the NFT has been returned during the collateral payout period + else if (block.timestamp > rental.dueDate) { + // Send the lender the collateral they are owed + _withdrawCollateral(_rentalId); + } + } + + /// @notice Transfers the amount of collateral owed to the lender + /// @dev Anyone can call to withdraw collateral to lender + /// @param _rentalId rental id + function withdrawCollateral(uint256 _rentalId) + public + rentalExists(_rentalId) + nonReentrant + { + _withdrawCollateral(_rentalId); + } + + function _withdrawCollateral(uint256 _rentalId) internal { + Rental storage rental = rentals[_rentalId]; + + // This can only be called after the rental due date has passed and the payout period has begun + if (block.timestamp <= rental.dueDate) revert InvalidState(); + + uint256 tardiness = block.timestamp - rental.dueDate; + uint256 payableAmount; + if (tardiness >= rental.collateralPayoutPeriod) { + payableAmount = rental.collateral; + } else { + payableAmount = + (tardiness * rental.collateral) / + rental.collateralPayoutPeriod; + } + + // sstore the collected collateral + rental.collectedCollateral = payableAmount; + + if (rental.ethIsDeposited && rental.nftIsDeposited) { + // Send the lender the collateral they're able to withdraw + payable(rental.lenderAddress).transfer(payableAmount); + // Send the borrower the collateral that is left + payable(rental.borrowerAddress).transfer( + rental.collateral - payableAmount + ); + } else { + // The lender never transferred the NFT so the borrow should be able to withdraw the entire balance + payable(rental.borrowerAddress).transfer( + rental.rentalPayment + rental.collateral + ); + } + } + + /// -------------------------------------------- /// + /// -------------- INTERNAL LOGIC -------------- /// + /// -------------------------------------------- /// + + // This function is automatically called by the contract when the final required assets are deposited + function _beginRental(uint256 _rentalId) internal { + rentals[_rentalId].rentalStartTime = block.timestamp; + } + + /// -------------------------------------------- /// + /// ----------- ERC721 RECEIVER LOGIC ---------- /// + /// -------------------------------------------- /// + + /// @notice Allows this contract to custody ERC721 Tokens + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) external pure returns (bytes4) { + return IERC721TokenReceiver.onERC721Received.selector; + } +} diff --git a/src/interfaces/IERC721TokenReceiver.sol b/src/interfaces/IERC721TokenReceiver.sol index d1792a1..2b264e8 100644 --- a/src/interfaces/IERC721TokenReceiver.sol +++ b/src/interfaces/IERC721TokenReceiver.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.10; +pragma solidity >=0.8.0; /// @notice A generic interface for a contract which properly accepts ERC721 tokens. /// @author Solmate (https://github.com/Rari-Capital/solmate/blob/main/src/tokens/ERC721.sol) diff --git a/src/test/RentalManager.t.sol b/src/test/RentalManager.t.sol new file mode 100644 index 0000000..8123271 --- /dev/null +++ b/src/test/RentalManager.t.sol @@ -0,0 +1,569 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.10; + +import {RentalManager} from "../RentalManager.sol"; +import {DSTestPlus} from "./utils/DSTestPlus.sol"; +import {MockERC721} from "./mocks/MockERC721.sol"; + +import {stdError, stdStorage, StdStorage} from "forge-std/stdlib.sol"; + +contract RentalManagerTest is DSTestPlus { + using stdStorage for StdStorage; + + RentalManager public rental; + + /// @dev Mock NFT + MockERC721 public mockNft; + + /// @dev Mock Actors + address public lenderAddress = address(69); + address public borrowerAddress = address(420); + + /// @dev Owned ERC721 Token Id + uint256 public tokenId = 1337; + + /// @dev Rental Parameters + uint256 public cachedTimestamp = block.timestamp; + uint256 public dueDate = cachedTimestamp + 100; + uint256 public rentalPayment = 10; + uint256 public collateral = 50; + uint256 public collateralPayoutPeriod = 40; + + function setUp() public { + // Create MockERC721 + mockNft = new MockERC721("Mock NFT", "MOCK"); + + // Mint the lender the owned token id + mockNft.mint(lenderAddress, tokenId); + + // Give the borrower enough balance + vm.deal(borrowerAddress, type(uint256).max); + + // Create RentalManager + rental = new RentalManager(); + + rental.createRental( + lenderAddress, + borrowerAddress, + address(mockNft), + tokenId, + dueDate, + rentalPayment, + collateral, + collateralPayoutPeriod + ); + } + + /// @notice Test Rental Creation + function testCreateRental() public { + // Expect Revert when we don't own the token id + hoax(address(1)); + vm.expectRevert(abi.encodePacked(bytes4(keccak256("NonTokenOwner()")))); + rental.createRental( + address(1), + borrowerAddress, + address(mockNft), + tokenId, + dueDate, + rentalPayment, + collateral, + collateralPayoutPeriod + ); + + // Expect Revert if the borrow doesn't have enough balance + address lender = address(1); + address borrower = address(2); + hoax(lender); + mockNft.mint(lender, tokenId + 1); + vm.deal(borrower, rentalPayment + collateral - 1); + vm.expectRevert( + abi.encodePacked(bytes4(keccak256("InsufficientValue()"))) + ); + rental.createRental( + lender, + borrower, + address(mockNft), + tokenId + 1, + dueDate, + rentalPayment, + collateral, + collateralPayoutPeriod + ); + } + + /// -------------------------------------------- /// + /// ---------------- DEPOSIT NFT --------------- /// + /// -------------------------------------------- /// + + /// @notice Tests depositing an NFT into the Rental Contract + function testDepositNFT() public { + // Expect Revert when we deposit to wrong rentalId + startHoax(lenderAddress); + vm.expectRevert( + abi.encodePacked(bytes4(keccak256("InvalidRentalId()"))) + ); + rental.depositNft(1); + vm.stopPrank(); + + // Expect Revert when we don't send from the lender address + startHoax(address(1)); + vm.expectRevert(abi.encodePacked(bytes4(keccak256("Unauthorized()")))); + rental.depositNft(0); + vm.stopPrank(); + + // Expect Revert if the lender doesn't own the token + startHoax(lenderAddress); + mockNft.transferFrom(lenderAddress, address(1), tokenId); + vm.expectRevert("WRONG_FROM"); + rental.depositNft(0); + vm.stopPrank(); + + // Transfer the token back to the lender + hoax(address(1)); + mockNft.transferFrom(address(1), lenderAddress, tokenId); + + // The Rental can't transfer if we don't approve it + hoax(lenderAddress); + vm.expectRevert("NOT_AUTHORIZED"); + rental.depositNft(0); + + // Rental should not have any eth deposited at this point + (, , , , bool ethIsDeposited, , , , , , , ) = rental.rentals(0); + assertFalse(ethIsDeposited); + + // The Lender Can Deposit + startHoax(lenderAddress); + mockNft.approve(address(rental), tokenId); + rental.depositNft(0); + vm.stopPrank(); + + // The rental should not have began since we didn't deposit eth + ( + , + , + , + bool nftIsDeposited, + , + , + , + , + , + , + uint256 rentalStartTime, + uint256 collectedCollateral + ) = rental.rentals(0); + assertTrue(nftIsDeposited); + assertEq(rentalStartTime, 0); + assertEq(collectedCollateral, 0); + + // We can't redeposit now even if we get the token back somehow + hoax(address(rental)); + mockNft.transferFrom(address(rental), lenderAddress, tokenId); + hoax(lenderAddress); + vm.expectRevert( + abi.encodePacked(bytes4(keccak256("AlreadyDeposited()"))) + ); + rental.depositNft(0); + } + + /// @notice Tests depositing the NFT into the contract after the borrower deposits eth + function testDepositETHthenNFT() public { + // Rental should not have any eth or nft deposited at this point + (, , , bool nftIsDeposited, bool ethIsDeposited, , , , , , , ) = rental + .rentals(0); + assertFalse(nftIsDeposited); + assertFalse(ethIsDeposited); + + // The Borrower can deposit eth + hoax(borrowerAddress); + rental.depositEth{value: rentalPayment + collateral}(0); + + // Eth should be deposited + uint256 rentalStartTime; + uint256 collectedCollateral; + ( + , + , + , + nftIsDeposited, + ethIsDeposited, + , + , + , + , + , + rentalStartTime, + collectedCollateral + ) = rental.rentals(0); + assertTrue(ethIsDeposited); + assertFalse(nftIsDeposited); + assertEq(rentalStartTime, 0); + assertEq(collectedCollateral, 0); + + // The Lender Can Deposit + startHoax(lenderAddress, 0); + mockNft.approve(address(rental), tokenId); + rental.depositNft(0); + vm.stopPrank(); + + // The rental should now begin! + ( + , + , + , + nftIsDeposited, + ethIsDeposited, + , + , + , + , + , + rentalStartTime, + + ) = rental.rentals(0); + assertTrue(nftIsDeposited); + assertTrue(ethIsDeposited); + + assertEq(mockNft.ownerOf(tokenId), borrowerAddress); + assertEq(lenderAddress.balance, rentalPayment); + + assertEq(rentalStartTime, block.timestamp); + } + + /// -------------------------------------------- /// + /// ---------------- DEPOSIT ETH --------------- /// + /// -------------------------------------------- /// + + /// @notice Tests depositing ETH into the Rental Contract + function testDepositETH() public { + // Expect Revert when we don't send from the borrower address + hoax(address(1)); + vm.expectRevert(abi.encodePacked(bytes4(keccak256("Unauthorized()")))); + rental.depositEth(0); + + // Expect Revert if not enough eth is sent as a value + hoax(borrowerAddress); + vm.expectRevert( + abi.encodePacked(bytes4(keccak256("InsufficientValue()"))) + ); + rental.depositEth(0); + + // Rental should not have any eth deposited at this point + (, , , , bool ethIsDeposited, , , , , , , ) = rental.rentals(0); + assertFalse(ethIsDeposited); + + // The Borrower can deposit eth + hoax(borrowerAddress); + rental.depositEth{value: rentalPayment + collateral}(0); + + // The rental should not have began since the lender hasn't deposited the nft + ( + , + , + , + bool nftIsDeposited, + bool ethIsDeposited2, + , + , + , + , + , + uint256 rentalStartTime, + + ) = rental.rentals(0); + assertTrue(ethIsDeposited2); + assertFalse(nftIsDeposited); + assertEq(rentalStartTime, 0); + + // We can't redeposit + hoax(borrowerAddress); + vm.expectRevert( + abi.encodePacked(bytes4(keccak256("AlreadyDeposited()"))) + ); + rental.depositEth(0); + } + + /// @notice Tests depositing ETH into the Rental Contract after the NFT is deposited + function testDepositNFTandETH() public { + // Rental should not have any eth or nft deposited at this point + (, , , bool nftIsDeposited, bool ethIsDeposited, , , , , , , ) = rental + .rentals(0); + assertFalse(ethIsDeposited); + assertFalse(nftIsDeposited); + + // The Lender Can Deposit + startHoax(lenderAddress); + mockNft.approve(address(rental), tokenId); + rental.depositNft(0); + vm.stopPrank(); + + // The nft should be deposited + (, , , nftIsDeposited, , , , , , , , ) = rental.rentals(0); + assertTrue(nftIsDeposited); + + // Set the lender's balance to 0 to realize the eth transferred from the contract + vm.deal(lenderAddress, 0); + + // The Borrower can deposit eth + hoax(borrowerAddress); + rental.depositEth{value: rentalPayment + collateral}(0); + + // The rental should now begin! + uint256 rentalStartTime; + (, , , nftIsDeposited, ethIsDeposited, , , , , , rentalStartTime, ) = rental.rentals( + 0 + ); + assertTrue(ethIsDeposited); + assertTrue(nftIsDeposited); + + assert(mockNft.ownerOf(tokenId) == borrowerAddress); + assert(lenderAddress.balance == rentalPayment); + + assert(rentalStartTime == block.timestamp); + } + + /// -------------------------------------------- /// + /// ---------------- WITHDRAW NFT -------------- /// + /// -------------------------------------------- /// + + /// @notice Test Withdrawing NFT + function testWithdrawNft() public { + uint256 fullPayment = rentalPayment + collateral; + + // Can't withdraw if the nft hasn't been deposited + hoax(lenderAddress); + vm.expectRevert(abi.encodePacked(bytes4(keccak256("InvalidState()")))); + rental.withdrawNft(0); + + // The Lender deposits + startHoax(lenderAddress, fullPayment); + mockNft.approve(address(rental), tokenId); + rental.depositNft(0); + vm.stopPrank(); + + // Can't withdraw if not the lender + hoax(address(1)); + vm.expectRevert(abi.encodePacked(bytes4(keccak256("Unauthorized()")))); + rental.withdrawNft(0); + + // The Lender doesn't own the NFT here + assertEq(mockNft.ownerOf(tokenId), address(rental)); + + // The lender can withdraw the NFT + hoax(lenderAddress, 0); + rental.withdrawNft(0); + + // The Lender should now own the Token + assertEq(mockNft.ownerOf(tokenId), lenderAddress); + } + + /// -------------------------------------------- /// + /// ---------------- WITHDRAW ETH -------------- /// + /// -------------------------------------------- /// + + /// @notice Test Withdrawing ETH + function testWithdrawETH() public { + uint256 fullPayment = rentalPayment + collateral; + + // Can't withdraw if the eth hasn't been deposited + hoax(borrowerAddress, fullPayment); + vm.expectRevert(abi.encodePacked(bytes4(keccak256("InvalidState()")))); + rental.withdrawEth(0); + + // The Borrower deposits + hoax(borrowerAddress, fullPayment); + rental.depositEth{value: fullPayment}(0); + + // Can't withdraw if not the borrower + hoax(address(1)); + vm.expectRevert(abi.encodePacked(bytes4(keccak256("Unauthorized()")))); + rental.withdrawEth(0); + + // Set both to have no eth + vm.deal(borrowerAddress, 0); + + // The borrower can withdraw the full contract balance + hoax(borrowerAddress, 0); + rental.withdrawEth(0); + + // The borrower should have their full deposit returned + assertEq(borrowerAddress.balance, fullPayment); + } + + /// -------------------------------------------- /// + /// ----------------- RETURN NFT --------------- /// + /// -------------------------------------------- /// + + /// @notice Tests returning the NFT on time + function testReturnNFT() public { + // The Lender deposits + startHoax(lenderAddress); + mockNft.approve(address(rental), tokenId); + rental.depositNft(0); + vm.stopPrank(); + + // The Borrower deposits + hoax(borrowerAddress); + rental.depositEth{value: rentalPayment + collateral}(0); + + // A non-owner of the erc721 token id shouldn't be able to transfer + hoax(address(1)); + vm.expectRevert("WRONG_FROM"); + rental.returnNft(0); + + // Can't transfer without approval + hoax(borrowerAddress); + vm.expectRevert("NOT_AUTHORIZED"); + rental.returnNft(0); + + // The borrower should own the NFT now + assertEq(mockNft.ownerOf(tokenId), borrowerAddress); + + // The owner should be able to return to the lender + startHoax(borrowerAddress, 0); + mockNft.approve(address(rental), tokenId); + rental.returnNft(0); + assertEq(borrowerAddress.balance, collateral); + assertEq(mockNft.ownerOf(tokenId), lenderAddress); + vm.stopPrank(); + } + + /// @notice Tests returning the NFT late + function testReturnNFTLate() public { + // The Lender deposits + startHoax(lenderAddress); + mockNft.approve(address(rental), tokenId); + rental.depositNft(0); + vm.stopPrank(); + + // The Borrower deposits + hoax(borrowerAddress); + rental.depositEth{value: rentalPayment + collateral}(0); + + // A non-owner of the erc721 token id shouldn't be able to transfer + hoax(address(1)); + vm.expectRevert("WRONG_FROM"); + rental.returnNft(0); + + // Can't transfer without approval + startHoax(borrowerAddress); + vm.expectRevert("NOT_AUTHORIZED"); + rental.returnNft(0); + vm.stopPrank(); + + // The borrower should own the NFT now + assertEq(mockNft.ownerOf(tokenId), borrowerAddress); + + // Jump to between the dueDate and full collateral payout + vm.warp(dueDate + collateralPayoutPeriod / 2); + + // Set the lender to have no eth + vm.deal(lenderAddress, 0); + + // The owner should be able to return to the lender with a decreased collateral return + startHoax(borrowerAddress, 0); + mockNft.approve(address(rental), tokenId); + rental.returnNft(0); + assertEq(borrowerAddress.balance, collateral / 2); + assertEq(lenderAddress.balance, collateral / 2); + assertEq(mockNft.ownerOf(tokenId), lenderAddress); + vm.stopPrank(); + } + + /// @notice Tests unable to return NFT since past collateral payout period + function testReturnNFTFail() public { + // The Lender deposits + startHoax(lenderAddress); + mockNft.approve(address(rental), tokenId); + rental.depositNft(0); + vm.stopPrank(); + + // The Borrower deposits + hoax(borrowerAddress); + rental.depositEth{value: rentalPayment + collateral}(0); + + // The borrower should own the NFT now + assertEq(mockNft.ownerOf(tokenId), borrowerAddress); + + // Jump to after the collateral payout period + vm.warp(dueDate + collateralPayoutPeriod); + + // Set the lender to have no eth + vm.deal(lenderAddress, 0); + + // The borrower can't return the nft now that it's past the payout period + // Realistically, this wouldn't be called by the borrower since it just transfers the NFT back to the lender + startHoax(borrowerAddress, 0); + mockNft.approve(address(rental), tokenId); + rental.returnNft(0); + assertEq(borrowerAddress.balance, 0); + assertEq(mockNft.ownerOf(tokenId), lenderAddress); + assertEq(lenderAddress.balance, collateral); + vm.stopPrank(); + } + + /// -------------------------------------------- /// + /// ------------- WITHDRAW COLLATERAL ---------- /// + /// -------------------------------------------- /// + + /// @notice Test withdrawing collateral + function testWithdrawCollateral() public { + // The Lender deposits + startHoax(lenderAddress); + mockNft.approve(address(rental), tokenId); + rental.depositNft(0); + vm.stopPrank(); + + // The Borrower deposits + hoax(borrowerAddress); + rental.depositEth{value: rentalPayment + collateral}(0); + + // Can't withdraw collateral before the dueDate + hoax(lenderAddress, 0); + vm.expectRevert(abi.encodePacked(bytes4(keccak256("InvalidState()")))); + rental.withdrawCollateral(0); + + // The borrower should own the NFT now + assertEq(mockNft.ownerOf(tokenId), borrowerAddress); + + // Jump to after the collateral payout period + vm.warp(dueDate + collateralPayoutPeriod); + + // Set both to have no eth + vm.deal(lenderAddress, 0); + vm.deal(borrowerAddress, 0); + + // The lender can withdraw the collateral + startHoax(lenderAddress, 0); + rental.withdrawCollateral(0); + assertEq(borrowerAddress.balance, 0); + assertEq(mockNft.ownerOf(tokenId), borrowerAddress); + assertEq(lenderAddress.balance, collateral); + vm.stopPrank(); + } + + /// @notice Test the borrower can withdraw the balance if the lender never deposits + function testWithdrawCollateralNoLender() public { + uint256 fullPayment = rentalPayment + collateral; + // The Borrower deposits + hoax(borrowerAddress, fullPayment); + rental.depositEth{value: fullPayment}(0); + + // Can't withdraw collateral before the dueDate + hoax(lenderAddress, 0); + vm.expectRevert(abi.encodePacked(bytes4(keccak256("InvalidState()")))); + rental.withdrawCollateral(0); + + // Jump to after the collateral payout period + vm.warp(dueDate + collateralPayoutPeriod); + + // Set both to have no eth + vm.deal(lenderAddress, 0); + vm.deal(borrowerAddress, 0); + + // The borrower can withdraw the full contract balance + hoax(borrowerAddress, 0); + rental.withdrawCollateral(0); + assertEq(borrowerAddress.balance, fullPayment); + } +}