diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2cf0664 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +BASE_RPC_URL=https://base.rpc.subquery.network/public +BASE_SEPOLIA_RPC_URL=https://base-sepolia-rpc.publicnode.com + +PRIVATE_KEY= diff --git a/.gitignore b/.gitignore index 85198aa..e9edb29 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ cache/ out/ # Ignores development broadcast logs -!/broadcast +/broadcast /broadcast/*/31337/ /broadcast/**/dry-run/ @@ -12,3 +12,5 @@ docs/ # Dotenv file .env + +dependencies/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 888d42d..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "lib/forge-std"] - path = lib/forge-std - url = https://github.com/foundry-rs/forge-std diff --git a/foundry.toml b/foundry.toml index 25b918f..d7016e5 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,14 @@ [profile.default] src = "src" out = "out" -libs = ["lib"] +libs = ["dependencies"] +auto_detect_remappings = true -# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options +[dependencies] +forge-std = { version = "1.9.1" } +"@openzeppelin-contracts" = { version = "5.0.2" } +"@openzeppelin-contracts-upgradeable" = { version = "5.0.2" } + +[rpc_endpoints] +base = "${BASE_RPC_URL}" +base_sepolia = "${BASE_SEPOLIA_RPC_URL}" diff --git a/lib/forge-std b/lib/forge-std deleted file mode 160000 index 07263d1..0000000 --- a/lib/forge-std +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 07263d193d621c4b2b0ce8b4d54af58f6957d97d diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..3a04b35 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,3 @@ +@openzeppelin/contracts=dependencies/@openzeppelin-contracts-5.0.2 +forge-std=dependencies/forge-std-1.9.1 +@openzeppelin/contracts-upgradeable=dependencies/@openzeppelin-contracts-upgradeable-5.0.2 \ No newline at end of file diff --git a/script/Counter.s.sol b/script/Counter.s.sol deleted file mode 100644 index cdc1fe9..0000000 --- a/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script, console} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/soldeer.lock b/soldeer.lock new file mode 100644 index 0000000..5594d5f --- /dev/null +++ b/soldeer.lock @@ -0,0 +1,18 @@ + +[[dependencies]] +name = "forge-std" +version = "1.9.1" +source = "https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip" +checksum = "110b35ad3604d91a919c521c71206c18cd07b29c750bd90b5cbbaf37288c9636" + +[[dependencies]] +name = "@openzeppelin-contracts" +version = "5.0.2" +source = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/5_0_2_14-03-2024_06:11:59_contracts.zip" +checksum = "8bc4f0acc7c187771b878d46f7de4bfad1acad2eb5d096d9d05d34035853f5c3" + +[[dependencies]] +name = "@openzeppelin-contracts-upgradeable" +version = "5.0.2" +source = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts-upgradeable/5_0_2_14-03-2024_06:12:07_contracts-upgradeable.zip" +checksum = "fb3f8db8541fc01636f91b0e7d9dd6f450f1bf7e2b4a17e96caf6e779ace8f5b" diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index aded799..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/Marketplace.sol b/src/Marketplace.sol new file mode 100644 index 0000000..3a631eb --- /dev/null +++ b/src/Marketplace.sol @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {RentalProductFactory} from "./RentalProductFactory.sol"; +import {RentalProduct} from "./RentalProduct.sol"; +import {IProduct} from "./interfaces/IProduct.sol"; + +contract Marketplace is Ownable { + // Statuses of a listing. WithdrawnOrNotExist, which is 0, is effectively the same as never listed before. + enum ListingStatus { + WithdrawnOrNotExist, + Listing, + Delisted + } + + // Statuses of a rental. EndedOrNotExist, which is 0, is effectively the same as never exist before. + enum RentalStatus { + EndedOrNotExist, + Renting + } + + struct ListingInfo { + address owner; + uint256 minRentalDays; + uint256 maxRentalDays; + address rentCurrency; + uint256 dailyRent; + address payable rentRecipient; + ListingStatus status; + } + + struct RentalInfo { + uint256 startTime; + uint256 endTime; + uint256 rentalDays; + address rentCurrency; + uint256 dailyRent; + uint256 totalPaidRent; + RentalStatus status; + } + + struct ListArgs { + address product; + uint256 tokenId; + uint256 minRentalDays; + uint256 maxRentalDays; + address rentCurrency; + uint256 dailyRent; + address rentRecipient; + } + + struct DelistArgs { + address payable rentalProduct; + uint256 tokenId; + } + + struct RelistArgs { + address payable rentalProduct; + uint256 tokenId; + uint256 minRentalDays; + uint256 maxRentalDays; + address rentCurrency; + uint256 dailyRent; + address payable rentRecipient; + } + + struct RentArgs { + address payable rentalProduct; + uint256 tokenId; + address tenant; + uint256 rentalDays; + uint256 prepaidRent; + } + + struct PayRentArgs { + address payable rentalProduct; + uint256 tokenId; + address tenant; + uint256 rent; + } + + struct EndLeaseArgs { + address payable rentalProduct; + uint256 tokenId; + address tenant; + } + + struct WithdrawArgs { + address payable rentalProduct; + uint256 tokenId; + } + + using SafeERC20 for IERC20; + + address private constant NATIVE_TOKEN = address(1); + + uint256 public constant MAX_POINTS = 10000; + + RentalProductFactory public immutable RENTAL_PRODUCT_FACTORY; + + address payable private _treasury; + + uint256 private _feePoints; + + /** + * @notice rent currency => is supported + */ + mapping(address => bool) public supportedRentCurrencies; + + /** + * @notice rental product => token id => listing info + */ + mapping(address => mapping(uint256 => ListingInfo)) public listings; + + /** + * @notice rental product => token id => tenant => rent info + */ + mapping(address => mapping(uint256 => mapping(address => RentalInfo))) + public rentals; + + constructor( + address initialOwner, + address rentalProductFactory, + address[] memory rentCurrencies, + address payable treasury, + uint256 feePoints + ) Ownable(initialOwner) { + RENTAL_PRODUCT_FACTORY = RentalProductFactory(rentalProductFactory); + for (uint i = 0; i < rentCurrencies.length; i++) { + supportedRentCurrencies[rentCurrencies[i]] = true; + } + _treasury = treasury; + _feePoints = feePoints; + } + + function setFeePoints(uint256 feePoints) external onlyOwner { + _feePoints = feePoints; + } + + function setTreasury(address payable treasury) external onlyOwner { + _treasury = treasury; + } + + function addRentCurrencies( + address[] memory rentCurrencies + ) external onlyOwner { + for (uint i = 0; i < rentCurrencies.length; i++) { + supportedRentCurrencies[rentCurrencies[i]] = true; + } + } + + function removeRentCurrencies( + address[] memory rentCurrencies + ) external onlyOwner { + for (uint i = 0; i < rentCurrencies.length; i++) { + supportedRentCurrencies[rentCurrencies[i]] = false; + } + } + + function list(ListArgs memory args) public { + address rentalProduct = RENTAL_PRODUCT_FACTORY.getRentalProduct( + args.product + ); + require(address(rentalProduct) != address(0), "unsupported nft"); + require( + listings[rentalProduct][args.tokenId].status == + ListingStatus.WithdrawnOrNotExist, // Never listed or withdrawn + "token already listed" + ); + require(args.minRentalDays > 0, "invalid minimum rental days"); + require( + args.maxRentalDays >= args.minRentalDays, + "invalid maximum rental days" + ); + require( + supportedRentCurrencies[args.rentCurrency], + "unsupported rent currency" + ); + + listings[rentalProduct][args.tokenId] = ListingInfo({ + owner: msg.sender, + minRentalDays: args.minRentalDays, + maxRentalDays: args.maxRentalDays, + rentCurrency: args.rentCurrency, + dailyRent: args.dailyRent, + rentRecipient: payable(args.rentRecipient), + status: ListingStatus.Listing + }); + + IProduct(args.product).transferFrom( + msg.sender, + address(this), + args.tokenId + ); + } + + function delist(DelistArgs memory args) public { + ListingInfo storage listing = listings[args.rentalProduct][ + args.tokenId + ]; + require(listing.owner == msg.sender, "not listing owner"); + listing.status = ListingStatus.Delisted; + } + + function relist(RelistArgs memory args) public { + ListingInfo storage listing = listings[args.rentalProduct][ + args.tokenId + ]; + require(listing.owner == msg.sender, "not listing owner"); + require(args.minRentalDays > 0, "invalid minimum rental days"); + require( + args.maxRentalDays >= args.minRentalDays, + "invalid maximum rental days" + ); + require( + supportedRentCurrencies[args.rentCurrency], + "unsupported rent currency" + ); + + listing.minRentalDays = args.minRentalDays; + listing.maxRentalDays = args.maxRentalDays; + listing.rentCurrency = args.rentCurrency; + listing.dailyRent = args.dailyRent; + listing.rentRecipient = args.rentRecipient; + listing.status = ListingStatus.Listing; + } + + function rent(RentArgs memory args) public payable { + require( + rentals[args.rentalProduct][args.tokenId][args.tenant].status == + RentalStatus.EndedOrNotExist, + "existing rental" + ); + + ListingInfo memory listing = listings[args.rentalProduct][args.tokenId]; + require( + listing.minRentalDays <= args.rentalDays && + args.rentalDays <= listing.maxRentalDays, + "invalid rental days" + ); + require( + args.prepaidRent >= listing.minRentalDays * listing.dailyRent, + "insufficient prepaid rent" + ); + + rentals[args.rentalProduct][args.tokenId][args.tenant] = RentalInfo({ + startTime: block.timestamp, + endTime: block.timestamp + args.rentalDays * 1 days, + rentalDays: args.rentalDays, + rentCurrency: listing.rentCurrency, + dailyRent: listing.dailyRent, + totalPaidRent: 0, + status: RentalStatus.Renting + }); + + // Pay rent + _payRent( + listing, + rentals[args.rentalProduct][args.tokenId][args.tenant], + args.prepaidRent + ); + // Add the tenant to the rental token + RentalProduct(payable(args.rentalProduct)).addUser( + args.tokenId, + args.tenant + ); + } + + function payRent(PayRentArgs memory args) public payable { + ListingInfo memory listing = listings[args.rentalProduct][args.tokenId]; + RentalInfo storage rental = rentals[args.rentalProduct][args.tokenId][ + args.tenant + ]; + require( + rental.totalPaidRent + args.rent <= + rental.rentalDays * rental.dailyRent, + "too much rent" + ); + + // Pay rent + _payRent(listing, rental, args.rent); + } + + function endLease(EndLeaseArgs memory args) public { + RentalInfo storage rental = rentals[args.rentalProduct][args.tokenId][ + args.tenant + ]; + // The lease can be ended only if the term is over or the rent is insufficient + uint256 rentNeeded = ((block.timestamp - rental.startTime) * + rental.dailyRent) / 1 days; + require( + rental.endTime < block.timestamp || + rental.totalPaidRent < rentNeeded, + "cannot end lease" + ); + + // Remove the tenant from the rental product + RentalProduct(args.rentalProduct).revokeUser(args.tokenId); + rental.status = RentalStatus.EndedOrNotExist; + } + + function withdraw(WithdrawArgs memory args) public { + ListingInfo storage listing = listings[args.rentalProduct][ + args.tokenId + ]; + require(listing.owner == msg.sender, "not listing owner"); + require( + RentalProduct(args.rentalProduct).isUser(args.tokenId, address(0)), + "rental product has user" + ); + listing.status = ListingStatus.WithdrawnOrNotExist; + + // fallback call: Transfer the nft back to the owner + IProduct(args.rentalProduct).safeTransferFrom( + address(this), + listing.owner, + args.tokenId + ); + } + + function _payRent( + ListingInfo memory listing, + RentalInfo storage rental, + uint256 rent_ + ) internal { + uint256 fee; + uint256 rentToOwner; + + bool feeOn = _feePoints != 0; + if (feeOn) { + fee = (rent_ * _feePoints) / MAX_POINTS; + rentToOwner = rent_ - fee; + } else { + rentToOwner = rent_; + } + + if (rental.rentCurrency == NATIVE_TOKEN) { + require(msg.value == rent_, "invalid prepaid rent"); + listing.rentRecipient.transfer(rentToOwner); + if (feeOn) { + _treasury.transfer(fee); + } + } else { + IERC20(rental.rentCurrency).safeTransferFrom( + msg.sender, + listing.rentRecipient, + rentToOwner + ); + if (feeOn) { + IERC20(rental.rentCurrency).safeTransferFrom( + msg.sender, + _treasury, + fee + ); + } + } + + rental.totalPaidRent += rent_; + } +} diff --git a/src/RentalProduct.sol b/src/RentalProduct.sol new file mode 100644 index 0000000..06b4faf --- /dev/null +++ b/src/RentalProduct.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IProduct} from "./interfaces/IProduct.sol"; + +contract RentalProduct { + IProduct public immutable PRODUCT; + + mapping(uint256 => address) public getUserByTokenId; + + constructor(IProduct product) { + PRODUCT = product; + } + + modifier onlyTokenOwner(uint256 tokenId) { + require( + msg.sender == PRODUCT.ownerOf(tokenId), + "not token owner" + ); + _; + } + + function addUser( + uint256 tokenId, + address user + ) external onlyTokenOwner(tokenId) { + getUserByTokenId[tokenId] = user; + } + + function revokeUser(uint256 tokenId) external onlyTokenOwner(tokenId) { + getUserByTokenId[tokenId] = address(0); + } + + function isUser(uint256 tokenId, address user) public view returns (bool) { + return getUserByTokenId[tokenId] == user; + } + + function _fallback(address logic) internal { + assembly { + let ptr := mload(0x40) + + // (1) copy incoming call data + calldatacopy(ptr, 0, calldatasize()) + + // (2) forward call to logic contract + let result := call( + gas(), + logic, + callvalue(), + ptr, + calldatasize(), + 0, + 0 + ) + let size := returndatasize() + + // (3) retrieve return data + returndatacopy(ptr, 0, size) + + // (4) forward return data back to caller + switch result + case 0 { + revert(ptr, size) + } + default { + return(ptr, size) + } + } + } + + fallback() external payable { + _fallback(address(PRODUCT)); + } + + receive() external payable {} +} diff --git a/src/RentalProductFactory.sol b/src/RentalProductFactory.sol new file mode 100644 index 0000000..029ae90 --- /dev/null +++ b/src/RentalProductFactory.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IProduct} from "./interfaces/IProduct.sol"; +import {RentalProduct} from "./RentalProduct.sol"; + +contract RentalProductFactory { + mapping (address => address) public getRentalProduct; + + function createRentalProduct( + address productAddress + ) external returns (address) + { + require( + address(getRentalProduct[productAddress]) == address(0), + "existing rental product" + ); + + RentalProduct rentalProduct = new RentalProduct(IProduct(productAddress)); + getRentalProduct[productAddress] = address(rentalProduct); + return address(rentalProduct); + } +} \ No newline at end of file diff --git a/src/interfaces/IProduct.sol b/src/interfaces/IProduct.sol new file mode 100644 index 0000000..8306b44 --- /dev/null +++ b/src/interfaces/IProduct.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +interface IProduct is IERC721 { + /** + * @notice Initializes the product with the given parameters. + * @param name The name of the product. + * @param symbol The symbol of the product. + * @param baseTokenURI The base URI for the product's tokens. + */ + function initialize( + string memory name, + string memory symbol, + string memory baseTokenURI + ) external; + + /** + * @notice Mints a new token to the specified address. + * @param to The address to mint the token to. + * @return uint256 The token ID of the newly minted token. + */ + function mint(address to) external returns (uint256); +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 54b724f..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -}