From 0b775aad932a445e5c81723dbf1202cfb7eb1e2a Mon Sep 17 00:00:00 2001 From: CanvasL <746591811@qq.com> Date: Tue, 30 Jul 2024 19:10:33 +0800 Subject: [PATCH] test: three main contracts --- src/AccessToken.sol | 6 +- src/AccessTokenFactory.sol | 2 +- src/Marketplace.sol | 44 +++-- test/AccessToken.t.sol | 80 ++++++++ test/AccessTokenFactory.t.sol | 44 +++++ test/Marketplace.t.sol | 336 ++++++++++++++++++++++++++++++++++ test/mocks/MockProduct.sol | 60 ++++++ 7 files changed, 552 insertions(+), 20 deletions(-) create mode 100644 test/AccessToken.t.sol create mode 100644 test/AccessTokenFactory.t.sol create mode 100644 test/Marketplace.t.sol create mode 100644 test/mocks/MockProduct.sol diff --git a/src/AccessToken.sol b/src/AccessToken.sol index c6caa0c..16a593c 100644 --- a/src/AccessToken.sol +++ b/src/AccessToken.sol @@ -16,7 +16,7 @@ contract AccessToken is ERC721 { } modifier onlyTokenOwner(uint256 tokenId) { - require(msg.sender == PRODUCT.ownerOf(tokenId), "not token owner"); + require(msg.sender == PRODUCT.ownerOf(tokenId), "not product owner"); _; } @@ -30,4 +30,8 @@ contract AccessToken is ERC721 { function burn(uint256 tokenId) external onlyTokenOwner(tokenId) { _burn(tokenId); } + + function isExist(uint256 tokenId) external view returns (bool) { + return _ownerOf(tokenId) != address(0); + } } diff --git a/src/AccessTokenFactory.sol b/src/AccessTokenFactory.sol index 9a0ea99..6e04edd 100644 --- a/src/AccessTokenFactory.sol +++ b/src/AccessTokenFactory.sol @@ -19,7 +19,7 @@ contract AccessTokenFactory { AccessToken accessToken = new AccessToken( IProduct(productAddress), ERC721(productAddress).name(), - ERC721(productAddress).symbol() + string(abi.encodePacked("AC.", ERC721(productAddress).symbol())) ); getAccessToken[productAddress] = address(accessToken); return address(accessToken); diff --git a/src/Marketplace.sol b/src/Marketplace.sol index 97cf520..8b1d6ef 100644 --- a/src/Marketplace.sol +++ b/src/Marketplace.sol @@ -30,13 +30,13 @@ contract Marketplace is IMarketplace, Ownable { /** * @notice access token => token id => listing info */ - mapping(address => mapping(uint256 => ListingInfo)) public listings; + mapping(address => mapping(uint256 => ListingInfo)) internal _listings; /** * @notice access token => token id => tenant => rent info */ mapping(address => mapping(uint256 => mapping(address => RentalInfo))) - public rentals; + internal _rentals; constructor( address initialOwner, @@ -53,6 +53,14 @@ contract Marketplace is IMarketplace, Ownable { _feePoints = feePoints; } + function getListingInfo(address accessToken, uint256 tokenId) external view returns (ListingInfo memory) { + return _listings[accessToken][tokenId]; + } + + function getRentalInfo(address accessToken, uint256 tokenId, address tenant) external view returns (RentalInfo memory) { + return _rentals[accessToken][tokenId][tenant]; + } + function setFeePoints(uint256 feePoints) external onlyOwner { _feePoints = feePoints; } @@ -85,7 +93,7 @@ contract Marketplace is IMarketplace, Ownable { accessToken = ACCESS_TOKEN_FACTORY.createAccessToken(args.product); } require( - listings[accessToken][args.tokenId].status == + _listings[accessToken][args.tokenId].status == ListingStatus.WithdrawnOrNotExist, // Never listed or withdrawn "token already listed" ); @@ -95,11 +103,11 @@ contract Marketplace is IMarketplace, Ownable { "invalid maximum rental days" ); require( - supportedRentCurrencies[args.rentCurrency], + args.rentCurrency == NATIVE_TOKEN || supportedRentCurrencies[args.rentCurrency], "unsupported rent currency" ); - listings[accessToken][args.tokenId] = ListingInfo({ + _listings[accessToken][args.tokenId] = ListingInfo({ owner: msg.sender, minRentalDays: args.minRentalDays, maxRentalDays: args.maxRentalDays, @@ -117,7 +125,7 @@ contract Marketplace is IMarketplace, Ownable { } function delist(DelistArgs memory args) public { - ListingInfo storage listing = listings[args.accessToken][ + ListingInfo storage listing = _listings[args.accessToken][ args.tokenId ]; require(listing.owner == msg.sender, "not listing owner"); @@ -125,7 +133,7 @@ contract Marketplace is IMarketplace, Ownable { } function relist(RelistArgs memory args) public { - ListingInfo storage listing = listings[args.accessToken][ + ListingInfo storage listing = _listings[args.accessToken][ args.tokenId ]; require(listing.owner == msg.sender, "not listing owner"); @@ -135,7 +143,7 @@ contract Marketplace is IMarketplace, Ownable { "invalid maximum rental days" ); require( - supportedRentCurrencies[args.rentCurrency], + args.rentCurrency == NATIVE_TOKEN || supportedRentCurrencies[args.rentCurrency], "unsupported rent currency" ); @@ -149,12 +157,12 @@ contract Marketplace is IMarketplace, Ownable { function rent(RentArgs memory args) public payable { require( - rentals[args.accessToken][args.tokenId][args.tenant].status == + _rentals[args.accessToken][args.tokenId][args.tenant].status == RentalStatus.EndedOrNotExist, "existing rental" ); - ListingInfo memory listing = listings[args.accessToken][args.tokenId]; + ListingInfo memory listing = _listings[args.accessToken][args.tokenId]; require( listing.minRentalDays <= args.rentalDays && args.rentalDays <= listing.maxRentalDays, @@ -165,7 +173,7 @@ contract Marketplace is IMarketplace, Ownable { "insufficient prepaid rent" ); - rentals[args.accessToken][args.tokenId][args.tenant] = RentalInfo({ + _rentals[args.accessToken][args.tokenId][args.tenant] = RentalInfo({ startTime: block.timestamp, endTime: block.timestamp + args.rentalDays * 1 days, rentalDays: args.rentalDays, @@ -178,7 +186,7 @@ contract Marketplace is IMarketplace, Ownable { // Pay rent _payRent( listing, - rentals[args.accessToken][args.tokenId][args.tenant], + _rentals[args.accessToken][args.tokenId][args.tenant], args.prepaidRent ); // Mint access token to tenant @@ -189,8 +197,8 @@ contract Marketplace is IMarketplace, Ownable { } function payRent(PayRentArgs memory args) public payable { - ListingInfo memory listing = listings[args.accessToken][args.tokenId]; - RentalInfo storage rental = rentals[args.accessToken][args.tokenId][ + ListingInfo memory listing = _listings[args.accessToken][args.tokenId]; + RentalInfo storage rental = _rentals[args.accessToken][args.tokenId][ args.tenant ]; require( @@ -204,7 +212,7 @@ contract Marketplace is IMarketplace, Ownable { } function endLease(EndLeaseArgs memory args) public { - RentalInfo storage rental = rentals[args.accessToken][args.tokenId][ + RentalInfo storage rental = _rentals[args.accessToken][args.tokenId][ args.tenant ]; // The lease can be ended only if the term is over or the rent is insufficient @@ -222,17 +230,17 @@ contract Marketplace is IMarketplace, Ownable { } function withdraw(WithdrawArgs memory args) public { - ListingInfo storage listing = listings[args.accessToken][ + ListingInfo storage listing = _listings[args.accessToken][ args.tokenId ]; require(listing.owner == msg.sender, "not listing owner"); require( - AccessToken(args.accessToken).ownerOf(args.tokenId) == address(0), + !AccessToken(args.accessToken).isExist(args.tokenId), "access token has tenant" ); listing.status = ListingStatus.WithdrawnOrNotExist; - (AccessToken(args.accessToken).PRODUCT()).safeTransferFrom( + (AccessToken(args.accessToken).PRODUCT()).transferFrom( address(this), listing.owner, args.tokenId diff --git a/test/AccessToken.t.sol b/test/AccessToken.t.sol new file mode 100644 index 0000000..14d3c92 --- /dev/null +++ b/test/AccessToken.t.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; +import {AccessToken} from "../src/AccessToken.sol"; +import {IProduct} from "../src/interfaces/IProduct.sol"; +import {MockProduct} from "./mocks/MockProduct.sol"; +import "forge-std/src/Test.sol"; + +contract AccessTokenTest is Test { + AccessToken accessToken; + MockProduct product; + + function setUp() public { + MockProduct productImpl = new MockProduct(); + product = MockProduct(Clones.clone(address(productImpl))); + product.initialize("MockProduct", "MP", "https://examples.com"); + + accessToken = new AccessToken( + IProduct(address(product)), + "AccessToken", + "AC" + ); + } + + function testMint() public { + vm.prank(address(this)); + uint256 tokenId = product.mint(address(this)); + + address user = makeAddr("user"); + accessToken.mint(user, tokenId); + + assertEq(accessToken.ownerOf(tokenId), user); + } + + function testMint_NotProductOwner() public { + vm.prank(address(this)); + uint256 tokenId = product.mint(address(this)); + + address notProductOwner = makeAddr("notProductOwner"); + vm.expectRevert("not product owner"); + vm.prank(notProductOwner); + accessToken.mint(notProductOwner, tokenId); + } + + function testBurn() public { + vm.prank(address(this)); + uint256 tokenId = product.mint(address(this)); + + accessToken.mint(address(this), tokenId); + accessToken.burn(tokenId); + + vm.expectRevert(); + accessToken.ownerOf(tokenId); + } + + function testBurn_NotProductOwner() public { + vm.prank(address(this)); + uint256 tokenId = product.mint(address(this)); + + accessToken.mint(address(this), tokenId); + + address notProductOwner = makeAddr("notProductOwner"); + vm.prank(notProductOwner); + vm.expectRevert("not product owner"); + accessToken.burn(tokenId); + } + + function testMint_AfterBurned() public { + vm.prank(address(this)); + uint256 tokenId = product.mint(address(this)); + + address user1 = makeAddr("user1"); + address user2 = makeAddr("user2"); + accessToken.mint(user1, tokenId); + accessToken.burn(tokenId); + accessToken.mint(user2, tokenId); + assertEq(accessToken.ownerOf(tokenId), user2); + } +} diff --git a/test/AccessTokenFactory.t.sol b/test/AccessTokenFactory.t.sol new file mode 100644 index 0000000..c3a4eb3 --- /dev/null +++ b/test/AccessTokenFactory.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {AccessTokenFactory} from "../src/AccessTokenFactory.sol"; +import {AccessToken} from "../src/AccessToken.sol"; +import {IProduct} from "../src/interfaces/IProduct.sol"; +import {MockProduct} from "./mocks/MockProduct.sol"; +import "forge-std/src/Test.sol"; + +contract AccessTokenFactoryTest is Test { + AccessTokenFactory factory; + MockProduct product; + + function setUp() public { + factory = new AccessTokenFactory(); + MockProduct productImpl = new MockProduct(); + product = MockProduct(Clones.clone(address(productImpl))); + product.initialize("MockProduct", "MP", "https://examples.com"); + } + + function testCreateAccessToken() public { + address productAddress = address(product); + + assertEq(factory.getAccessToken(productAddress), address(0)); + + address accessTokenAddress = factory.createAccessToken(productAddress); + assertTrue(accessTokenAddress != address(0)); + assertEq(factory.getAccessToken(productAddress), accessTokenAddress); + + AccessToken accessToken = AccessToken(accessTokenAddress); + assertEq(accessToken.name(), "MockProduct"); + assertEq(accessToken.symbol(), "AC.MP"); + } + + function testCreateAccessTokenTwice() public { + address productAddress = address(product); + factory.createAccessToken(productAddress); + + vm.expectRevert("existing access token"); + factory.createAccessToken(productAddress); + } +} \ No newline at end of file diff --git a/test/Marketplace.t.sol b/test/Marketplace.t.sol new file mode 100644 index 0000000..fb45141 --- /dev/null +++ b/test/Marketplace.t.sol @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {AccessTokenFactory} from "../src/AccessTokenFactory.sol"; +import {IMarketplace} from "../src/interfaces/IMarketplace.sol"; +import {Marketplace} from "../src/Marketplace.sol"; +import {AccessToken} from "../src/AccessToken.sol"; +import {IProduct} from "../src/interfaces/IProduct.sol"; +import {MockProduct} from "./mocks/MockProduct.sol"; +import "forge-std/src/Test.sol"; + +contract AccessTokenFactoryTest is Test { + AccessTokenFactory factory; + MockProduct product; + Marketplace marketplace; + + address treasury; + uint256 feePoints = 100; + + function setUp() public { + treasury = makeAddr("treasury"); + + factory = new AccessTokenFactory(); + MockProduct productImpl = new MockProduct(); + product = MockProduct(Clones.clone(address(productImpl))); + product.initialize("MockProduct", "MP", "https://examples.com"); + + marketplace = new Marketplace( + address(this), + address(factory), + new address[](0), + payable(treasury), + feePoints + ); + } + + function testList() public { + address productAddress = address(product); + + // Mint the product token to this contract + uint256 tokenId = product.mint(address(this)); + + // List the product token on the marketplace + IMarketplace.ListArgs memory args = IMarketplace.ListArgs({ + product: productAddress, + tokenId: tokenId, + minRentalDays: 1, + maxRentalDays: 30, + rentCurrency: address(0), + dailyRent: 1 ether, + rentRecipient: payable(address(this)) + }); + + product.approve(address(marketplace), tokenId); + marketplace.list(args); + + // Check that the token is listed + IMarketplace.ListingInfo memory listing = marketplace.getListingInfo( + address(factory.getAccessToken(productAddress)), + tokenId + ); + assertEq(listing.owner, address(this)); + assertEq(listing.minRentalDays, 1); + assertEq(listing.maxRentalDays, 30); + assertEq(listing.rentCurrency, address(0)); + assertEq(listing.dailyRent, 1 ether); + assertEq( + uint256(listing.status), + uint256(IMarketplace.ListingStatus.Listing) + ); + } + + function testDelist() public { + address productAddress = address(product); + uint256 tokenId = product.mint(address(this)); + + // List the product token on the marketplace + IMarketplace.ListArgs memory listArgs = IMarketplace.ListArgs({ + product: productAddress, + tokenId: tokenId, + minRentalDays: 1, + maxRentalDays: 30, + rentCurrency: address(0), + dailyRent: 1 ether, + rentRecipient: payable(address(this)) + }); + + product.approve(address(marketplace), tokenId); + marketplace.list(listArgs); + + // Delist the token + IMarketplace.DelistArgs memory delistArgs = IMarketplace.DelistArgs({ + accessToken: payable(factory.getAccessToken(productAddress)), + tokenId: tokenId + }); + + marketplace.delist(delistArgs); + + // Check that the token is delisted + IMarketplace.ListingInfo memory listing = marketplace.getListingInfo( + address(factory.getAccessToken(productAddress)), + tokenId + ); + assertEq( + uint256(listing.status), + uint256(IMarketplace.ListingStatus.Delisted) + ); + } + + function testRent() public { + address productAddress = address(product); + uint256 tokenId = product.mint(address(this)); + + address tenant = makeAddr("tenant"); + vm.deal(tenant, 10 ether); + + // List the product token on the marketplace + IMarketplace.ListArgs memory listArgs = IMarketplace.ListArgs({ + product: productAddress, + tokenId: tokenId, + minRentalDays: 1, + maxRentalDays: 30, + rentCurrency: address(0), + dailyRent: 1 ether, + rentRecipient: payable(address(this)) + }); + + product.approve(address(marketplace), tokenId); + marketplace.list(listArgs); + + // Rent the token + IMarketplace.RentArgs memory rentArgs = IMarketplace.RentArgs({ + accessToken: factory.getAccessToken(productAddress), + tokenId: tokenId, + tenant: tenant, + rentalDays: 5, + prepaidRent: 5 ether + }); + + vm.prank(tenant); + marketplace.rent{value: 5 ether}(rentArgs); + + // Check that the token is rented + IMarketplace.RentalInfo memory rental = marketplace.getRentalInfo( + address(factory.getAccessToken(productAddress)), + tokenId, + tenant + ); + assertEq(rental.startTime, block.timestamp); + assertEq(rental.endTime, block.timestamp + 5 days); + assertEq(rental.rentalDays, 5); + assertEq(rental.dailyRent, 1 ether); + assertEq( + uint256(rental.status), + uint256(IMarketplace.RentalStatus.Renting) + ); + } + + function testPayRent() public { + address productAddress = address(product); + uint256 tokenId = product.mint(address(this)); + + address tenant = makeAddr("tenant"); + vm.deal(tenant, 20 ether); + + // List the product token on the marketplace + IMarketplace.ListArgs memory listArgs = IMarketplace.ListArgs({ + product: productAddress, + tokenId: tokenId, + minRentalDays: 1, + maxRentalDays: 30, + rentCurrency: address(0), + dailyRent: 1 ether, + rentRecipient: payable(address(this)) + }); + + product.approve(address(marketplace), tokenId); + marketplace.list(listArgs); + + // Rent the token + IMarketplace.RentArgs memory rentArgs = IMarketplace.RentArgs({ + accessToken: factory.getAccessToken(productAddress), + tokenId: tokenId, + tenant: tenant, + rentalDays: 8, + prepaidRent: 5 ether + }); + + vm.prank(tenant); + marketplace.rent{value: 5 ether}(rentArgs); + + IMarketplace.RentalInfo memory rental = marketplace.getRentalInfo( + address(factory.getAccessToken(productAddress)), + tokenId, + tenant + ); + assertEq(rental.startTime, block.timestamp); + assertEq(rental.endTime, block.timestamp + 8 days); // 5 initial days + 3 additional days + assertEq(rental.rentalDays, 8); + assertEq(rental.dailyRent, 1 ether); + assertEq(rental.totalPaidRent, 5 ether); + assertEq( + uint256(rental.status), + uint256(IMarketplace.RentalStatus.Renting) + ); + + // Pay additional rent + IMarketplace.PayRentArgs memory payRentArgs = IMarketplace.PayRentArgs({ + accessToken: factory.getAccessToken(productAddress), + tokenId: tokenId, + tenant: tenant, + rent: 3 ether + }); + + vm.prank(tenant); + marketplace.payRent{value: 3 ether}(payRentArgs); + + // Check that the total paid rent is increment + rental = marketplace.getRentalInfo( + address(factory.getAccessToken(productAddress)), + tokenId, + tenant + ); + assertEq(rental.totalPaidRent, 8 ether); + } + + function testEndLease() public { + address productAddress = address(product); + uint256 tokenId = product.mint(address(this)); + + // address receiver = makeAddr("receiver"); + address tenant = makeAddr("tenant"); + vm.deal(tenant, 10 ether); + + // List the product token on the marketplace + IMarketplace.ListArgs memory listArgs = IMarketplace.ListArgs({ + product: productAddress, + tokenId: tokenId, + minRentalDays: 1, + maxRentalDays: 30, + rentCurrency: address(0), + dailyRent: 1 ether, + rentRecipient: payable(address(this)) + }); + + product.approve(address(marketplace), tokenId); + marketplace.list(listArgs); + + // Rent the token + IMarketplace.RentArgs memory rentArgs = IMarketplace.RentArgs({ + accessToken: factory.getAccessToken(productAddress), + tokenId: tokenId, + tenant: tenant, + rentalDays: 5, + prepaidRent: 5 ether + }); + + vm.prank(tenant); + marketplace.rent{value: 5 ether}(rentArgs); + + // Advance time to end the rental period + vm.warp(block.timestamp + 6 days); + + // End the lease + IMarketplace.EndLeaseArgs memory endLeaseArgs = IMarketplace + .EndLeaseArgs({ + accessToken: factory.getAccessToken(productAddress), + tokenId: tokenId, + tenant: tenant + }); + + vm.prank(tenant); + marketplace.endLease(endLeaseArgs); + + // Check that the lease is ended + IMarketplace.RentalInfo memory rental = marketplace.getRentalInfo( + address(factory.getAccessToken(productAddress)), + tokenId, + tenant + ); + assertEq( + uint256(rental.status), + uint256(IMarketplace.RentalStatus.EndedOrNotExist) + ); + } + + function testWithdraw() public { + address productAddress = address(product); + uint256 tokenId = product.mint(address(this)); + + // List the product token on the marketplace + IMarketplace.ListArgs memory listArgs = IMarketplace.ListArgs({ + product: productAddress, + tokenId: tokenId, + minRentalDays: 1, + maxRentalDays: 30, + rentCurrency: address(0), + dailyRent: 1 ether, + rentRecipient: payable(address(this)) + }); + + product.approve(address(marketplace), tokenId); + marketplace.list(listArgs); + + // Delist the token + IMarketplace.DelistArgs memory delistArgs = IMarketplace.DelistArgs({ + accessToken: payable(factory.getAccessToken(productAddress)), + tokenId: tokenId + }); + + marketplace.delist(delistArgs); + + // Withdraw the token + IMarketplace.WithdrawArgs memory withdrawArgs = IMarketplace + .WithdrawArgs({ + accessToken: factory.getAccessToken(productAddress), + tokenId: tokenId + }); + + marketplace.withdraw(withdrawArgs); + + // Check that the token is withdrawn + IMarketplace.ListingInfo memory listing = marketplace.getListingInfo( + address(factory.getAccessToken(productAddress)), + tokenId + ); + assertEq( + uint256(listing.status), + uint256(IMarketplace.ListingStatus.WithdrawnOrNotExist) + ); + } + + receive() external payable {} +} diff --git a/test/mocks/MockProduct.sol b/test/mocks/MockProduct.sol new file mode 100644 index 0000000..d03de89 --- /dev/null +++ b/test/mocks/MockProduct.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.24; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {ERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IProduct} from "../../src/interfaces/IProduct.sol"; + +contract MockProduct is + Initializable, + OwnableUpgradeable, + ERC721Upgradeable, + IProduct +{ + string public BASE_TOKEN_URI; + uint256 private _tokenIdCount; + + constructor() { + _disableInitializers(); + } + + /** + * @inheritdoc IProduct + */ + function initialize( + string memory name, + string memory symbol, + string memory baseTokenURI + ) public initializer { + __ERC721_init(name, symbol); + __Ownable_init(msg.sender); + BASE_TOKEN_URI = baseTokenURI; + _tokenIdCount = 1; + } + + /** + * @inheritdoc IProduct + */ + function mint(address to) public onlyOwner returns (uint256) { + uint256 tokenId = _tokenIdCount++; + _mint(to, tokenId); + return tokenId; + } + + /** + * @inheritdoc IERC165 + */ + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721Upgradeable, IERC165) returns (bool) { + return + interfaceId == type(IProduct).interfaceId || + super.supportsInterface(interfaceId); + } + + function _baseURI() internal view override returns (string memory) { + return BASE_TOKEN_URI; + } +}