From e975bec7536c6b7f52a6f1747b726061a85a4a1c Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Mon, 6 May 2024 22:48:05 -0300 Subject: [PATCH 1/2] Feat: add marketplace offers logic tests --- constants/constructor-args.ts | 5 + constants/contract.ts | 9 +- .../deployments-game7-arb-sepolia.ts | 28 +- test/hardhatTests/englishAuctions.test.ts | 4 +- test/hardhatTests/helpers/types.ts | 45 ++- test/hardhatTests/marketplaceOffers.test.ts | 359 ++++++++++++++++++ 6 files changed, 440 insertions(+), 10 deletions(-) create mode 100644 test/hardhatTests/marketplaceOffers.test.ts diff --git a/constants/constructor-args.ts b/constants/constructor-args.ts index 9aa35fb..cf57c11 100644 --- a/constants/constructor-args.ts +++ b/constants/constructor-args.ts @@ -486,4 +486,9 @@ export const EnglishAuctionsExtensionArgs = { TESTNET: { _nativeTokenWrapper: IRON_G7_ARB_SEPOLIA_NATIVE_TOKEN, }, +}; + +export const OffersExtensionArgs = { + MAINNET: {}, + TESTNET: {}, }; \ No newline at end of file diff --git a/constants/contract.ts b/constants/contract.ts index 614d330..df06ada 100644 --- a/constants/contract.ts +++ b/constants/contract.ts @@ -20,7 +20,8 @@ export enum CONTRACT_TYPE { RewardToken = 'RewardToken', DirectListingExtension = 'DirectListingExtension', Marketplace = 'Marketplace', - EnglishAuctionsExtension = 'EnglishAuctionsExtension' + EnglishAuctionsExtension = 'EnglishAuctionsExtension', + OffersExtension = 'OffersExtension' } export enum PROXY_CONTRACT_TYPE { @@ -57,7 +58,8 @@ export enum CONTRACT_FILE_NAME { DirectListingsExtension = 'DirectListingsLogic', ERC20 = 'MockERC20', Marketplace = 'Marketplace', - EnglishAuctionsExtension = 'EnglishAuctionsLogic' + EnglishAuctionsExtension = 'EnglishAuctionsLogic', + OffersExtension = 'OffersLogic' } export enum CONTRACT_UPGRADABLE_FILE_NAME { @@ -103,7 +105,8 @@ export enum CONTRACT_NAME { DirectListingExtension = 'DirectListingExtension', MartinERC20 = 'MockERC20', Marketplace = 'Marketplace', - EnglishAuctionsExtension = 'EnglishAuctionsExtension' + EnglishAuctionsExtension = 'EnglishAuctionsExtension', + OffersExtension = 'OffersExtension' } export enum CONTRACT_UPGRADABLE_NAME { diff --git a/constants/proxy-deployments/deployments-game7-arb-sepolia.ts b/constants/proxy-deployments/deployments-game7-arb-sepolia.ts index ef0842c..a89d683 100644 --- a/constants/proxy-deployments/deployments-game7-arb-sepolia.ts +++ b/constants/proxy-deployments/deployments-game7-arb-sepolia.ts @@ -9,7 +9,12 @@ import { import { TENANT } from '@constants/tenant'; import { DeploymentProxyContract } from '../../types/deployment-type'; import { NETWORK_TYPE, NetworkName } from '../network'; -import {DirectListingExtensionArgs, EnglishAuctionsExtensionArgs, MarketplaceArgs} from '@constants/constructor-args'; +import { + DirectListingExtensionArgs, + EnglishAuctionsExtensionArgs, + MarketplaceArgs, + OffersExtensionArgs, +} from '@constants/constructor-args'; const chain = NetworkName.Game7OrbitArbSepolia; const networkType = NETWORK_TYPE.TESTNET; @@ -90,6 +95,27 @@ export const GAME7_ARB_SEPOLIA_CONTRACTS: DeploymentProxyContract[] = [ 'isAuctionExpired(uint256)', ], }, + { + contractFileName: CONTRACT_FILE_NAME.OffersExtension, + type: CONTRACT_TYPE.OffersExtension, + name: CONTRACT_NAME.OffersExtension, + verify: true, + extensionArgs: OffersExtensionArgs.TESTNET, + metadata: { + name: 'OffersLogic', + metadataURI: 'ipfs://{hash}', + implementation: `CONTRACT_${CONTRACT_NAME.OffersExtension}`, + }, + functionsToInclude: [ + 'makeOffer((address,uint256,uint256,address,uint256,uint256))', + 'cancelOffer(uint256)', + 'acceptOffer(uint256)', + 'totalOffers()', + 'getOffer(uint256)', + 'getAllOffers(uint256,uint256)', + 'getAllValidOffers(uint256,uint256)', + ], + }, ], implementationArgs: MarketplaceArgs.TESTNET, }, diff --git a/test/hardhatTests/englishAuctions.test.ts b/test/hardhatTests/englishAuctions.test.ts index 6def37a..a5f7e34 100644 --- a/test/hardhatTests/englishAuctions.test.ts +++ b/test/hardhatTests/englishAuctions.test.ts @@ -12,7 +12,7 @@ import { import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers'; import { deployMarketplaceContracts } from './fixture/marketplaceContractsFixture'; import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; -import { Auction, AuctionParameters, AuctionStatus, TokenType } from './helpers/types'; +import { Auction, AuctionParameters, Status, TokenType } from './helpers/types'; import { NATIVE_TOKEN, ONE_DAY, ONE_HOUR } from './helpers/constants'; import { ASSET_ROLE, LISTER_ROLE } from './helpers/roles'; import { ZeroAddress } from 'ethers'; @@ -195,7 +195,7 @@ describe('EnglishAuction', function () { assetContract: auctionParameters.assetContract, currency: auctionParameters.currency, tokenType: TokenType.ERC721, - status: AuctionStatus.CREATED, + status: Status.CREATED, }; }); diff --git a/test/hardhatTests/helpers/types.ts b/test/hardhatTests/helpers/types.ts index 2f4c078..1bcd48c 100644 --- a/test/hardhatTests/helpers/types.ts +++ b/test/hardhatTests/helpers/types.ts @@ -13,10 +13,11 @@ export type AuctionParameters = { export enum TokenType { ERC721, - ERC1155 + ERC1155, + ERC20 } -export enum AuctionStatus { +export enum Status { UNSET, CREATED, COMPLETED, @@ -37,5 +38,41 @@ export type Auction = { assetContract: string; currency: string; tokenType: TokenType; - status: AuctionStatus; -} \ No newline at end of file + status: Status; +} + +export type OfferParams = { + assetContract: string; + tokenId: number; + quantity: number; + currency: string; + totalPrice: bigint; + expirationTimestamp: number; +} + +/* +struct Offer { + uint256 offerId; + uint256 tokenId; + uint256 quantity; + uint256 totalPrice; + uint256 expirationTimestamp; + address offeror; + address assetContract; + address currency; + TokenType tokenType; + Status status; +}*/ + +export type Offer = { + offerId: bigint; + tokenId: number; + quantity: number; + totalPrice: bigint; + expirationTimestamp: number; + offeror: string; + assetContract: string; + currency: string; + tokenType: TokenType; + status: Status; +} diff --git a/test/hardhatTests/marketplaceOffers.test.ts b/test/hardhatTests/marketplaceOffers.test.ts new file mode 100644 index 0000000..39dc060 --- /dev/null +++ b/test/hardhatTests/marketplaceOffers.test.ts @@ -0,0 +1,359 @@ +import { expect } from 'chai'; +import { ethers } from 'hardhat'; +import { + Marketplace, + MockERC1155, + MockERC20, + MockERC721, + MockRoyaltyEngineV1, + OffersLogic, + Permissions, +} from '../../typechain-types'; +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers'; +import { deployMarketplaceContracts } from './fixture/marketplaceContractsFixture'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { toWei } from './helpers/misc'; +import { Offer, OfferParams, Status, TokenType } from './helpers/types'; +import { ONE_DAY } from './helpers/constants'; +import { ASSET_ROLE } from './helpers/roles'; +import { ZeroAddress } from 'ethers'; + +describe('Marketplace: Offers', function () { + let offersLogic: OffersLogic; + let marketplace: Marketplace; + let permissions: Permissions; + let mockRoyaltyEngineV1: MockRoyaltyEngineV1; + let mockERC20: MockERC20; + let mockERC721: MockERC721; + let mockERC1155: MockERC1155; + let deployer: SignerWithAddress; + let seller: SignerWithAddress; + let buyer: SignerWithAddress; + let royaltyRecipient: SignerWithAddress; + + let mockERC721Address: string; + let mockERC1155Address: string; + let mockERC20Address: string; + let marketplaceAddress: string; + let blockTimestamp: number; + + const tokenId = 0; + const buyerBalance = toWei('1000'); + const zeroBalance = BigInt(0); + const sftBalance = toWei('1000'); + const totalPrice = toWei('10'); + const quantity = 1; + const royaltyAmount = toWei('0.1'); + + beforeEach(async function () { + [deployer, buyer, seller, royaltyRecipient] = await ethers.getSigners(); + + [marketplace, mockERC20, mockERC721, mockERC1155, mockRoyaltyEngineV1] = await loadFixture( + deployMarketplaceContracts + ); + + marketplaceAddress = await marketplace.getAddress(); + mockERC20Address = await mockERC20.getAddress(); + mockERC721Address = await mockERC721.getAddress(); + mockERC1155Address = await mockERC1155.getAddress(); + blockTimestamp = await time.latest(); + + await mockERC721.mint(seller.address); + await mockERC721.connect(seller).setApprovalForAll(marketplaceAddress, true); + + await mockERC1155.mint(seller.address, tokenId, sftBalance, '0x'); + await mockERC1155.connect(seller).setApprovalForAll(marketplaceAddress, true); + + await mockERC20.mint(buyer.address, buyerBalance); + await mockERC20.connect(buyer).approve(marketplaceAddress, buyerBalance); + + offersLogic = await ethers.getContractAt('OffersLogic', marketplaceAddress); + permissions = await ethers.getContractAt('Permissions', marketplaceAddress); + }); + + describe('Offers', function () { + describe('When Offers does not exist', function () { + describe('Make Offer', function () { + let offerParams: OfferParams; + + beforeEach(async function () { + offerParams = { + assetContract: mockERC721Address, + tokenId: tokenId, + quantity, + currency: mockERC20Address, + totalPrice, + expirationTimestamp: blockTimestamp + ONE_DAY, + }; + }); + + it('Should makeOffer an offer for ERC721', async function () { + const offerId = await offersLogic.connect(buyer).makeOffer.staticCall(offerParams); + const offer: Offer = { + offerId: offerId, + tokenId: offerParams.tokenId, + quantity: offerParams.quantity, + totalPrice: offerParams.totalPrice, + expirationTimestamp: offerParams.expirationTimestamp, + offeror: buyer.address, + assetContract: offerParams.assetContract, + currency: offerParams.currency, + tokenType: TokenType.ERC721, + status: Status.CREATED, + }; + await expect(offersLogic.connect(buyer).makeOffer(offerParams)) + .to.emit(offersLogic, 'NewOffer') + .withArgs(buyer.address, offerId, offerParams.assetContract, Object.values(offer)); + }); + it('Should makeOffer an offer for ERC1155', async function () { + offerParams.assetContract = mockERC1155Address; + offerParams.quantity = 2; + const offerId = await offersLogic.connect(buyer).makeOffer.staticCall(offerParams); + const offer: Offer = { + offerId: offerId, + tokenId: offerParams.tokenId, + quantity: offerParams.quantity, + totalPrice: offerParams.totalPrice, + expirationTimestamp: offerParams.expirationTimestamp, + offeror: buyer.address, + assetContract: offerParams.assetContract, + currency: offerParams.currency, + tokenType: TokenType.ERC1155, + status: Status.CREATED, + }; + await expect(offersLogic.connect(buyer).makeOffer(offerParams)) + .to.emit(offersLogic, 'NewOffer') + .withArgs(buyer.address, offerId, offerParams.assetContract, Object.values(offer)); + }); + it('Should revert if total price is 0', async function () { + offerParams.totalPrice = BigInt(0); + await expect(offersLogic.connect(buyer).makeOffer(offerParams)).to.be.revertedWith('zero price.'); + }); + it('Should revert if quantity is 0', async function () { + offerParams.quantity = 0; + await expect(offersLogic.connect(buyer).makeOffer(offerParams)).to.be.revertedWith( + 'Marketplace: wanted zero tokens.' + ); + }); + it('Should revert if quantity is greater than 1 for ERC721', async function () { + offerParams.quantity = 2; + await expect(offersLogic.connect(buyer).makeOffer(offerParams)).to.be.revertedWith( + 'Marketplace: wanted invalid quantity.' + ); + }); + it('Should revert if insufficient currency balance', async function () { + offerParams.totalPrice = buyerBalance + BigInt(1); + await expect(offersLogic.connect(buyer).makeOffer(offerParams)).to.be.revertedWith( + 'Marketplace: insufficient currency balance.' + ); + }); + it(`Should revert if asset if assetContract does not have ASSET_ROLE(${ASSET_ROLE})`, async function () { + await permissions.revokeRole(ASSET_ROLE, ZeroAddress); + await expect(offersLogic.connect(buyer).makeOffer(offerParams)).to.be.revertedWith('!ASSET_ROLE'); + }); + it('Should revert if expiration timestamp is older than one hour', async function () { + offerParams.expirationTimestamp = blockTimestamp - ONE_DAY; + await expect(offersLogic.connect(buyer).makeOffer(offerParams)).to.be.revertedWith( + 'Marketplace: invalid expiration timestamp.' + ); + }); + it('Should revert if token type is neither ERC1155 nor ERC721', async function () { + offerParams.assetContract = mockERC20Address; + await expect(offersLogic.connect(buyer).makeOffer(offerParams)).to.be.revertedWith( + 'Marketplace: token must be ERC1155 or ERC721.' + ); + }); + }); + }); + describe('When Offers exists', function () { + let offer: Offer; + let offerParams: OfferParams; + beforeEach(async function () { + offerParams = { + assetContract: mockERC721Address, + tokenId: tokenId, + quantity, + currency: mockERC20Address, + totalPrice, + expirationTimestamp: blockTimestamp + ONE_DAY, + }; + const offerId = await offersLogic.connect(buyer).makeOffer.staticCall(offerParams); + await offersLogic.connect(buyer).makeOffer(offerParams); + offer = { + offerId: offerId, + tokenId: offerParams.tokenId, + quantity: offerParams.quantity, + totalPrice: offerParams.totalPrice, + expirationTimestamp: offerParams.expirationTimestamp, + offeror: buyer.address, + assetContract: offerParams.assetContract, + currency: offerParams.currency, + tokenType: TokenType.ERC721, + status: Status.CREATED, + }; + }); + + describe('Cancel Offer', function () { + it('Should cancel an offer', async function () { + await expect(offersLogic.connect(buyer).cancelOffer(offer.offerId)) + .to.emit(offersLogic, 'CancelledOffer') + .withArgs(buyer.address, offer.offerId); + }); + it('Should revert if offer does not exist', async function () { + await expect(offersLogic.connect(buyer).cancelOffer(offer.offerId + BigInt(1))).to.be.revertedWith( + 'Marketplace: invalid offer.' + ); + }); + it('Should revert if offeror is not the caller', async function () { + await expect(offersLogic.connect(seller).cancelOffer(offer.offerId)).to.be.revertedWith('!Offeror'); + }); + }); + + describe('Accept Offer', function () { + it('Should accept an offer for ERC721', async function () { + expect(await mockERC20.balanceOf(buyer.address)).to.equal(buyerBalance); + expect(await mockERC20.balanceOf(seller.address)).to.equal(zeroBalance); + expect(await mockERC721.ownerOf(tokenId)).to.equal(seller.address); + + await expect(offersLogic.connect(seller).acceptOffer(offer.offerId)) + .to.emit(offersLogic, 'AcceptedOffer') + .withArgs( + offer.offeror, + offer.offerId, + offer.assetContract, + offer.tokenId, + seller.address, + offer.quantity, + offer.totalPrice + ); + + expect(await mockERC20.balanceOf(buyer.address)).to.equal(buyerBalance - totalPrice); + expect(await mockERC20.balanceOf(seller.address)).to.equal(totalPrice); + expect(await mockERC721.ownerOf(tokenId)).to.equal(buyer.address); + }); + it('Should accept an offer for ERC1155', async function () { + expect(await mockERC20.balanceOf(buyer.address)).to.equal(buyerBalance); + expect(await mockERC20.balanceOf(seller.address)).to.equal(zeroBalance); + expect(await mockERC1155.balanceOf(seller.address, tokenId)).to.equal(sftBalance); + + offer.assetContract = mockERC1155Address; + offer.tokenType = TokenType.ERC1155; + offer.offerId = await offersLogic.connect(buyer).makeOffer.staticCall({ + ...offerParams, + assetContract: mockERC1155Address, + }); + await offersLogic.connect(buyer).makeOffer({ ...offerParams, assetContract: mockERC1155Address }); + await expect(offersLogic.connect(seller).acceptOffer(offer.offerId)) + .to.emit(offersLogic, 'AcceptedOffer') + .withArgs( + offer.offeror, + offer.offerId, + offer.assetContract, + offer.tokenId, + seller.address, + offer.quantity, + offer.totalPrice + ); + + expect(await mockERC20.balanceOf(buyer.address)).to.equal(buyerBalance - totalPrice); + expect(await mockERC20.balanceOf(seller.address)).to.equal(totalPrice); + expect(await mockERC1155.balanceOf(seller.address, tokenId)).to.equal( + sftBalance - BigInt(quantity) + ); + }); + it('Should accept an offer if royalty is set', async function () { + const recipients = [royaltyRecipient.address]; + const amounts = [royaltyAmount]; + await mockRoyaltyEngineV1.setRoyalty(recipients, amounts); + + expect(await mockERC20.balanceOf(royaltyRecipient.address)).to.equal(zeroBalance); + expect(await mockERC20.balanceOf(buyer.address)).to.equal(buyerBalance); + expect(await mockERC20.balanceOf(seller.address)).to.equal(zeroBalance); + expect(await mockERC721.ownerOf(tokenId)).to.equal(seller.address); + + await expect(offersLogic.connect(seller).acceptOffer(offer.offerId)) + .to.emit(offersLogic, 'AcceptedOffer') + .withArgs( + offer.offeror, + offer.offerId, + offer.assetContract, + offer.tokenId, + seller.address, + offer.quantity, + offer.totalPrice + ); + + expect(await mockERC20.balanceOf(royaltyRecipient.address)).to.equal(royaltyAmount); + expect(await mockERC20.balanceOf(buyer.address)).to.equal(buyerBalance - totalPrice); + expect(await mockERC20.balanceOf(seller.address)).to.equal(totalPrice - royaltyAmount); + expect(await mockERC721.ownerOf(tokenId)).to.equal(buyer.address); + }); + it('Should revert if royalty is set and royalty amount is greater than total price', async function () { + const recipients = [royaltyRecipient.address]; + const amounts = [totalPrice + BigInt(1)]; + await mockRoyaltyEngineV1.setRoyalty(recipients, amounts); + + await expect(offersLogic.connect(seller).acceptOffer(offer.offerId)).to.be.revertedWith( + 'fees exceed the price' + ); + }); + it('Should revert if offer does not exist', async function () { + await expect(offersLogic.connect(seller).acceptOffer(offer.offerId + BigInt(1))).to.be.revertedWith( + 'Marketplace: invalid offer.' + ); + }); + it('Should revert if offer is expired', async function () { + await time.increase(offer.expirationTimestamp + 1); + await expect(offersLogic.connect(seller).acceptOffer(offer.offerId)).to.be.revertedWith('EXPIRED'); + }); + it('Should revert if offeror has insufficient currency balance', async function () { + await mockERC20.connect(buyer).transfer(seller.address, buyerBalance); + await expect(offersLogic.connect(seller).acceptOffer(offer.offerId)).to.be.revertedWith( + 'Marketplace: insufficient currency balance.' + ); + }); + it('Should revert if marketplace is not approved to transfer asset', async function () { + await mockERC721.connect(seller).setApprovalForAll(marketplaceAddress, false); + await expect(offersLogic.connect(seller).acceptOffer(offer.offerId)).to.be.revertedWith( + 'Marketplace: not owner or approved tokens.' + ); + }); + it('Should revert if seller does not have the asset', async function () { + await mockERC721.connect(seller).transferFrom(seller.address, buyer.address, tokenId); + await expect(offersLogic.connect(seller).acceptOffer(offer.offerId)).to.be.revertedWith( + 'Marketplace: not owner or approved tokens.' + ); + }); + }); + + describe('View Functions', async function () { + describe('totalOffers', function () { + it('Should return total offers', async function () { + expect(await offersLogic.totalOffers()).to.be.equal(1); + }); + }); + describe('getOffer', function () { + it('Should return offer', async function () { + expect(await offersLogic.getOffer(offer.offerId)).to.be.deep.equal(Object.values(offer)); + }); + }); + describe('getAllOffers', function () { + it('Should return all offers', async function () { + expect(await offersLogic.getAllOffers(0, 0)).to.be.deep.equal([Object.values(offer)]); + }); + it('Should revert if start id is greater than total offers', async function () { + await expect(offersLogic.getAllOffers(1, 0)).to.be.revertedWith('invalid range'); + }); + }); + describe('getAllValidOffers', function () { + it('Should return all valid offers', async function () { + expect(await offersLogic.getAllValidOffers(0, 0)).to.be.deep.equal([Object.values(offer)]); + }); + it('Should revert if start id is greater than total offers', async function () { + await expect(offersLogic.getAllValidOffers(1, 0)).to.be.revertedWith('invalid range'); + }); + }); + }); + }); + }); +}); From 9cd063e4bdb39f4d5f7b47d2fbf7db7c972a5fdc Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Mon, 6 May 2024 22:50:57 -0300 Subject: [PATCH 2/2] Fix: remove comment --- test/hardhatTests/helpers/types.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/test/hardhatTests/helpers/types.ts b/test/hardhatTests/helpers/types.ts index 1bcd48c..aae9970 100644 --- a/test/hardhatTests/helpers/types.ts +++ b/test/hardhatTests/helpers/types.ts @@ -50,20 +50,6 @@ export type OfferParams = { expirationTimestamp: number; } -/* -struct Offer { - uint256 offerId; - uint256 tokenId; - uint256 quantity; - uint256 totalPrice; - uint256 expirationTimestamp; - address offeror; - address assetContract; - address currency; - TokenType tokenType; - Status status; -}*/ - export type Offer = { offerId: bigint; tokenId: number;