From 4b261d925a45f692dfd9432a66227a384fc794ba Mon Sep 17 00:00:00 2001 From: Emanuele Bellocchia <54482000+ebellocchia@users.noreply.github.com> Date: Wed, 4 Oct 2023 11:36:41 +0200 Subject: [PATCH] First commit --- .env | 12 + .gitignore | 14 + .solcover.js | 3 + .solhint.json | 3 + CHANGELOG.md | 3 + LICENSE | 9 + README.md | 462 ++++++++++++ clean.bat | 11 + clean.sh | 13 + contracts/ERC1967Proxy.sol | 12 + contracts/IERC20Receiver.sol | 33 + contracts/NftsAuction.sol | 670 ++++++++++++++++++ contracts/NftsManagerBase.sol | 477 +++++++++++++ contracts/NftsRedeemer.sol | 467 ++++++++++++ contracts/NftsSeller.sol | 509 +++++++++++++ contracts/test/ERC20FixedSupply.sol | 50 ++ contracts/test/MockERC1155Token.sol | 67 ++ contracts/test/MockERC20Receiver.sol | 79 +++ contracts/test/MockERC20Token.sol | 40 ++ contracts/test/MockERC721Token.sol | 53 ++ contracts/test/NftsAuctionUpgraded.sol | 28 + contracts/test/NftsRedeemerUpgraded.sol | 28 + contracts/test/NftsSellerUpgraded.sol | 28 + hardhat.config.ts | 105 +++ package.json | 47 ++ reset.bat | 2 + reset.sh | 4 + tasks/deploy.ts | 193 +++++ test/auction/NftsAuction.Access.ts | 65 ++ test/auction/NftsAuction.Bid.ts | 316 +++++++++ test/auction/NftsAuction.Complete.ts | 251 +++++++ test/auction/NftsAuction.Create.ts | 332 +++++++++ test/auction/NftsAuction.Deploy.ts | 46 ++ test/auction/NftsAuction.ERC20Receiver.ts | 51 ++ .../NftsAuction.PaymentERC20Address.ts | 25 + test/auction/NftsAuction.Remove.ts | 114 +++ test/auction/NftsAuction.Withdraw.ts | 135 ++++ test/auction/UtilsAuction.ts | 186 +++++ test/common/Constants.ts | 15 + test/common/TestPaymentERC20Address.ts | 28 + test/common/TestWithdraw.ts | 109 +++ test/common/UtilsCommon.ts | 210 ++++++ test/redeemer/NftsRedeemer.Access.ts | 58 ++ test/redeemer/NftsRedeemer.Create.ts | 305 ++++++++ test/redeemer/NftsRedeemer.Deploy.ts | 63 ++ test/redeemer/NftsRedeemer.ERC20Receiver.ts | 42 ++ .../NftsRedeemer.PaymentERC20Address.ts | 25 + test/redeemer/NftsRedeemer.Redeem.ts | 150 ++++ test/redeemer/NftsRedeemer.Remove.ts | 65 ++ test/redeemer/NftsRedeemer.Withdraw.ts | 125 ++++ test/redeemer/UtilsRedeemer.ts | 133 ++++ test/seller/NftsSeller.Access.ts | 59 ++ test/seller/NftsSeller.Buy.ts | 199 ++++++ test/seller/NftsSeller.Create.ts | 217 ++++++ test/seller/NftsSeller.Deploy.ts | 46 ++ test/seller/NftsSeller.ERC20Receiver.ts | 51 ++ test/seller/NftsSeller.PaymentERC20Address.ts | 25 + test/seller/NftsSeller.Remove.ts | 82 +++ test/seller/NftsSeller.Withdraw.ts | 123 ++++ test/seller/UtilsSeller.ts | 129 ++++ tsconfig.json | 11 + 61 files changed, 7213 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 .solcover.js create mode 100644 .solhint.json create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 clean.bat create mode 100644 clean.sh create mode 100644 contracts/ERC1967Proxy.sol create mode 100644 contracts/IERC20Receiver.sol create mode 100644 contracts/NftsAuction.sol create mode 100644 contracts/NftsManagerBase.sol create mode 100644 contracts/NftsRedeemer.sol create mode 100644 contracts/NftsSeller.sol create mode 100644 contracts/test/ERC20FixedSupply.sol create mode 100644 contracts/test/MockERC1155Token.sol create mode 100644 contracts/test/MockERC20Receiver.sol create mode 100644 contracts/test/MockERC20Token.sol create mode 100644 contracts/test/MockERC721Token.sol create mode 100644 contracts/test/NftsAuctionUpgraded.sol create mode 100644 contracts/test/NftsRedeemerUpgraded.sol create mode 100644 contracts/test/NftsSellerUpgraded.sol create mode 100644 hardhat.config.ts create mode 100644 package.json create mode 100644 reset.bat create mode 100644 reset.sh create mode 100644 tasks/deploy.ts create mode 100644 test/auction/NftsAuction.Access.ts create mode 100644 test/auction/NftsAuction.Bid.ts create mode 100644 test/auction/NftsAuction.Complete.ts create mode 100644 test/auction/NftsAuction.Create.ts create mode 100644 test/auction/NftsAuction.Deploy.ts create mode 100644 test/auction/NftsAuction.ERC20Receiver.ts create mode 100644 test/auction/NftsAuction.PaymentERC20Address.ts create mode 100644 test/auction/NftsAuction.Remove.ts create mode 100644 test/auction/NftsAuction.Withdraw.ts create mode 100644 test/auction/UtilsAuction.ts create mode 100644 test/common/Constants.ts create mode 100644 test/common/TestPaymentERC20Address.ts create mode 100644 test/common/TestWithdraw.ts create mode 100644 test/common/UtilsCommon.ts create mode 100644 test/redeemer/NftsRedeemer.Access.ts create mode 100644 test/redeemer/NftsRedeemer.Create.ts create mode 100644 test/redeemer/NftsRedeemer.Deploy.ts create mode 100644 test/redeemer/NftsRedeemer.ERC20Receiver.ts create mode 100644 test/redeemer/NftsRedeemer.PaymentERC20Address.ts create mode 100644 test/redeemer/NftsRedeemer.Redeem.ts create mode 100644 test/redeemer/NftsRedeemer.Remove.ts create mode 100644 test/redeemer/NftsRedeemer.Withdraw.ts create mode 100644 test/redeemer/UtilsRedeemer.ts create mode 100644 test/seller/NftsSeller.Access.ts create mode 100644 test/seller/NftsSeller.Buy.ts create mode 100644 test/seller/NftsSeller.Create.ts create mode 100644 test/seller/NftsSeller.Deploy.ts create mode 100644 test/seller/NftsSeller.ERC20Receiver.ts create mode 100644 test/seller/NftsSeller.PaymentERC20Address.ts create mode 100644 test/seller/NftsSeller.Remove.ts create mode 100644 test/seller/NftsSeller.Withdraw.ts create mode 100644 test/seller/UtilsSeller.ts create mode 100644 tsconfig.json diff --git a/.env b/.env new file mode 100644 index 0000000..d416fe8 --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +BSCSCAN_API_KEY="YOUR_API_KEY" +ETHERSCAN_API_KEY="YOUR_API_KEY" +POLYGONSCAN_API_KEY="YOUR_API_KEY" + +BSC_TESTNET_RPC="https://data-seed-prebsc-1-s1.binance.org:8545" +BSC_MAINNET_RPC="https://bsc-dataseed1.binance.org" +ETH_TESTNET_RPC="https://sepolia.infura.io/v3/YOUR_API_KEY" +ETH_MAINNET_RPC="https://mainnet.infura.io/v3/YOUR_API_KEY" +POLYGON_TESTNET_RPC="https://rpc-mumbai.maticvigil.com/v1/YOUR_API_KEY" +POLYGON_MAINNET_RPC="https://rpc-mainnet.maticvigil.com/v1/YOUR_API_KEY" + +MNEMONIC="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c16ccd --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +abi +docs +node_modules +coverage +coverage.json +metadata +package-lock.json +typechain +typechain-types +*Flattened.sol + +# Hardhat files +cache +artifacts diff --git a/.solcover.js b/.solcover.js new file mode 100644 index 0000000..7d4b837 --- /dev/null +++ b/.solcover.js @@ -0,0 +1,3 @@ +module.exports = { + skipFiles: ["test"] +}; diff --git a/.solhint.json b/.solhint.json new file mode 100644 index 0000000..d7c3de9 --- /dev/null +++ b/.solhint.json @@ -0,0 +1,3 @@ +{ + "extends": "solhint:default" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..89c09bc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# 0.1.0 + +First release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6cd36b1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2020 Emanuele Bellocchia + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..60f5ad1 --- /dev/null +++ b/README.md @@ -0,0 +1,462 @@ +# Introduction + +The package contains 3 smart contracts: + +- `NftsAuction`: it allows to auction ERC721 or ERC1155 tokens by paying in ERC20 tokens (e.g. stablecoins) +- `NftsSeller`: it allows to sell ERC721 or ERC1155 tokens by paying in ERC20 tokens (e.g. stablecoins) +- `NftsRedeemer`: it allows to reserve a ERC721 or ERC1155 token for a specific wallet, which can redeem it by paying in ERC20 tokens (e.g. stablecoins) + +Each smart contract is upgradeable using a UUPS proxy, so it's deployed together with a `ERC1967Proxy` proxy contract. + +# Setup + +Install `yarn` if not installed: + + npm install -g yarn + +## Install package + +Simply run: + + npm i --include=dev + +## Compile + +- To compile the contract: + + yarn compile + +- To compile by starting from a clean build: + + yarn recompile + +## Run tests + +- To run tests without coverage: + + yarn test + +- To run tests with coverage: + + yarn coverage + +## Deploy + +- To deploy all contracts: + + yarn deploy-all --wallet-addr + +- To deploy the `NftsAuction` contract: + + yarn deploy-auction --wallet-addr + +- To deploy the `NftsRedeemer` contract: + + yarn deploy-redeemer --wallet-addr + +- To deploy the `NftsSeller` contract: + + yarn deploy-seller --wallet-addr + +- To upgrade the `NftsAuction` contract: + + yarn upgrade-auction-to --proxy-addr + +- To upgrade the `NftsRedeemer` contract: + + yarn upgrade-redeemer-to --proxy-addr + +- To upgrade the `NftsSeller` contract: + + yarn upgrade-seller-to --proxy-addr + +- To deploy the test tokens (i.e. `MockERC20Token`, `MockERC721Token`, `MockERC1155Token`): + + yarn deploy-test-tokens --erc20-supply + +## Configuration + +Hardhat is configured with the following networks: + +|Network name|Description| +|---|---| +|`hardhat`|Hardhat built-in network| +|`locahost`|Localhost network (address: `127.0.0.1:8545`, it can be run with the following command: `yarn run-node`)| +|`bscTestnet`|Zero address| +|`bsc`|BSC mainnet| +|`ethereumSepolia`|ETH testnet (Sepolia)| +|`ethereum`|ETH mainnet| +|`polygonMumbai`|Polygon testnet (Mumbai)| +|`polygon`|Polygon mainnet| + +The API keys, RPC nodes and mnemonic shall be configured in the `.env` file.\ +You may need to modify the gas limit and price in the Hardhat configuration file for some networks (e.g. Polygon), to successfully execute the transactions (you'll get a gas error). + +### Common functions + +Functions implemented by all contracts. + +___ + + function init( + address paymentERC20Address_ + ) initializer + +Initialize the contract with the specified address for receiving ERC20 tokens.\ +The function is an `initializer`, so it can be called only once. + +The function is usually called by the `ERC1967Proxy` that manages the contract. + +___ + + function setPaymentERC20Address( + address paymentERC20Address_ + ) onlyOwner + +Set the address where the ERC20 tokens paid by users will be transferred.\ +If the address is a contract, the function: + + function onERC20Received( + IERC20 token_, + uint256 amount_ + ) returns (bytes4) + +will be automatically called on the contract to manage the received tokens. +On success, the function shall return its Solidity selector (i.e. `IERC20Receiver.onERC20Received.selector`).\ +In order to implement the function, the contract should derive the `IERC20Receiver` interface and override it. + +The function can be only called by the owner. + +### "NftsSeller" functions + + function createERC721Sale( + IERC721 nftContract_, + uint256 nftId_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) onlyOwner + +Create a sale for a ERC721 token with address `nftContract_` and ID `nftId_`. The token shall be owned by the contract.\ +The price of the token is `erc20Amount_` of the ERC20 token with address `erc20Contract_`. + +The function can be only called by the owner. + +___ + + function createERC1155Sale( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) onlyOwner + +Create a sale for a ERC1155 token with address `nftContract_`, ID `nftId_` and amount `nftAmount_`. The token shall be owned by the contract.\ +The price of the token is `erc20Amount_` of the ERC20 token with address `erc20Contract_`. + +The function can be only called by the owner. + +___ + + function removeSale( + address nftContract_, + uint256 nftId_ + ) onlyOwner + +Remove the sale of the token (ERC721 or ERC1155) with address `nftContract_` and ID `nftId_`. + +The function can be only called by the owner. + +___ + + function withdrawERC721( + IERC721 nftContract_, + uint256 nftId_ + ) onlyOwner + +Withdraw the ERC721 token with address `nftContract_` and ID `nftId_`, transferring it to the contract owner.\ +The token shall not be for sale at that moment. In case it is, it shall be removed before calling the function. + +The function can be only called by the owner. + +___ + + function withdrawERC1155( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) onlyOwner + +Withdraw the ERC1155 token with address `nftContract_`, ID `nftId_` and amount `nftAmount_`, transferring it to the contract owner.\ +In case the token is for sale at that moment, only the amount of token that is not on redeem can be withdrawn.\ +For example, if the contract owns 10 tokens and 6 tokens are on redeem, at maximum 4 tokens can be withdrawn by the owner. + +The function can be only called by the owner. + +___ + + function buyERC721( + IERC721 nftContract_, + uint256 nftId_ + ) + +Buy the ERC721 token with address `nftContract_` and ID `nftId_`, transferring it to the caller.\ +The caller has to pay the ERC20 token amount, set when creating the sale, in exchange of the ERC721 token. + +The ERC20 token will be transferred to the payment ERC20 address set by `setPaymentERC20Address`. + +___ + + function buyERC1155( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) + +Buy the ERC1155 token with address `nftContract_`, ID `nftId_` and amount `nftAmount_`, transferring it to the caller.\ +The caller has to pay the ERC20 token amount, set when creating the sale, in exchange of the ERC1155 token. + +The ERC20 token will be transferred to the payment ERC20 address set by `setPaymentERC20Address`. + +___ + + function isSaleActive( + address nftContract_, + uint256 nftId_ + ) + +Get if the token sale with address `nftContract_` and ID `nftId_` is active. + +### "NftsRedeemer" functions + + function createERC721Redeem( + address redeemer_, + IERC721 nftContract_, + uint256 nftId_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) onlyOwner + +Reserve the ERC721 token with address `nftContract_`, ID `nftId_` and amount `nftAmount_` for the user `redeemer_`. +The token shall be owned by the contract.\ +The price of the token is `erc20Amount_` of the ERC20 token with address `erc20Contract_`. + +The function can be only called by the owner. + +___ + + function createERC1155Redeem( + address redeemer_, + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) onlyOwner + +Reserve the ERC1155 token with address `nftContract_` and ID `nftId_` for the user `redeemer_`. The token shall be owned by the contract.\ +The price of the token is `erc20Amount_` of the ERC20 token with address `erc20Contract_`. + +The function can be only called by the owner. + +___ + + function removeRedeem( + address redeemer_ + ) onlyOwner + +Remove the token reservation for the user `redeemer_`. + +The function can be only called by the owner. + +___ + + function withdrawERC721( + IERC721 nftContract_, + uint256 nftId_ + ) onlyOwner + +Withdraw the ERC721 token with address `nftContract_` and ID `nftId_`, transferring it to the contract owner.\ +The token shall not be reserved. In case it is, it shall be removed before calling the function. + +The function can be only called by the owner. + +___ + + function withdrawERC1155( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) onlyOwner + +Withdraw the ERC1155 token with address `nftContract_`, ID `nftId_` and amount `nftAmount_`, transferring it to the contract owner.\ +In case the token is reserved, only the amount of token that is not reserved can be withdrawn.\ +For example, if the contract owns 10 tokens and 6 tokens are reserved, at maximum 4 tokens can be withdrawn by the owner. + +The function can be only called by the owner. + +___ + + function redeemToken() + +Redeem the token reserved for the caller, transferring it to it.\ +The caller has to pay the ERC20 token amount, set when creating the redeem, in exchange of the token. + +The ERC20 token will be transferred to the payment ERC20 address set by `setPaymentERC20Address`. + +___ + + function isRedeemActive( + address redeemer_ + ) + +Get if the user address `redeemer_` has an active redeem. + +___ + + function isRedeemActive( + address nftContract_, + uint256 nftId_ + ) + +Get if the redeem for the token with address `nftContract_` and ID `nftId_` is active. + +### "NftsAuction" functions + + function createERC721Auction( + IERC721 nftContract_, + uint256 nftId_, + IERC20 erc20Contract_, + uint256 erc20StartPrice_, + uint256 erc20MinimumBidIncrement_, + uint256 durationSec_, + uint256 extendTimeSec_ + ) onlyOwner + +Create an auction for a ERC721 token with address `nftContract_` and ID `nftId_`. The token shall be owned by the contract.\ +The starting price of the auction is `erc20StartPrice_` amount of the ERC20 token with address `erc20Contract_` +and the minimum bid increment is `erc20MinimumBidIncrement_` of the same token.\ +The auction will last `erc20MinimumBidIncrement_` seconds. + +If a user bids when the auction is expiring in `extendTimeSec_` seconds, the auction will be extended of `extendTimeSec_` seconds. +To disable this behavior, set it to zero. + +The function can be only called by the owner. + +___ + + function createERC1155Auction( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20StartPrice_, + uint256 erc20MinimumBidIncrement_, + uint256 durationSec_, + uint256 extendTimeSec_ + ) onlyOwner + +Create an auction for a ERC1155 token with address `nftContract_`, ID `nftId_` and amount `nftAmount_`. The token shall be owned by the contract.\ +The starting price of the auction is `erc20StartPrice_` amount of the ERC20 token with address `erc20Contract_` +and the minimum bid increment is `erc20MinimumBidIncrement_` of the same token.\ +The auction will last `erc20MinimumBidIncrement_` seconds. + +If a user bids when the auction is expiring in `extendTimeSec_` seconds, the auction will be extended of `extendTimeSec_` seconds. +To disable this behavior, set it to zero. + +The function can be only called by the owner. + +___ + + function removeAuction( + address nftContract_, + uint256 nftId_ + ) onlyOwner + +Remove the auction of the token (ERC721 or ERC1155) with address `nftContract_` and ID `nftId_`. + +The function can be only called by the owner. + +___ + + function withdrawERC721( + IERC721 nftContract_, + uint256 nftId_ + ) onlyOwner + +Withdraw the ERC721 token with address `nftContract_` and ID `nftId_`, transferring it to the contract owner.\ +The token shall not be on auction at that moment. In case it is, it shall be removed before calling the function. + +The function can be only called by the owner. + +___ + + function withdrawERC1155( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) onlyOwner + +Withdraw the ERC1155 token with address `nftContract_`, ID `nftId_` and amount `nftAmount_`, transferring it to the contract owner.\ +In case the token is on auction at that moment, only the amount of token that is not on auction can be withdrawn.\ +For example, if the contract owns 10 tokens and 6 tokens are on auction, at maximum 4 tokens can be withdrawn by the owner. + +The function can be only called by the owner. + +___ + + function bidAtAuction( + address nftContract_, + uint256 nftId_, + uint256 erc20BidAmount_ + ) + +Bid at the auction of the token (ERC721 or ERC1155) with address `nftContract_` and ID `nftId_`. +The bid amount is `erc20BidAmount_` of the ERC20 token set for the auction. + +The bid is valid if: + +- `erc20BidAmount_` is higher than the current highest bid plus the minimum bid amount set for the auction +- The auction is not expired + +___ + + function completeAuction( + address nftContract_, + uint256 nftId_ + ) + +Complete the auction of the token (ERC721 or ERC1155) with address `nftContract_` and ID `nftId_`.\ +The function is called by the winner of the auction to get the token and pay for it.\ +The caller has to pay the ERC20 token amount, that he bid, in exchange of the token. + +The function can be only called by the winner of the auction. + +The ERC20 token will be transferred to the payment ERC20 address set by `setPaymentERC20Address`. + +___ + + function isAuctionActive( + address nftContract_, + uint256 nftId_ + ) + +Get if the auction for the token with address `nftContract_` and ID `nftId_` is active. + +___ + + function isAuctionExpired( + address nftContract_, + uint256 nftId_ + ) + +Get if the auction for the token with address `nftContract_` and ID `nftId_` is expired. + +___ + + function isAuctionCompleted( + address nftContract_, + uint256 nftId_ + ) + +Get if the auction for the token with address `nftContract_` and ID `nftId_` is completed. diff --git a/clean.bat b/clean.bat new file mode 100644 index 0000000..a0308fb --- /dev/null +++ b/clean.bat @@ -0,0 +1,11 @@ +rmdir /s /q abi 2>nul +rmdir /s /q artifacts 2>nul +rmdir /s /q cache 2>nul +rmdir /s /q coverage 2>nul +rmdir /s /q docs 2>nul +rmdir /s /q metadata 2>nul +rmdir /s /q node_modules 2>nul +rmdir /s /q typechain-types 2>nul +del /f /q package-lock.json 2>nul +del /f /q coverage.json 2>nul +del /f /q *Flattened.sol 2>nul diff --git a/clean.sh b/clean.sh new file mode 100644 index 0000000..bf851f6 --- /dev/null +++ b/clean.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +rm -rf abi +rm -rf artifacts +rm -rf cache +rm -rf coverage +rm -rf docs +rm -rf metadata +rm -rf node_modules +rm -rf typechain-types +rm -f package-lock.json +rm -f coverage.json +rm -f *Flattened.sol diff --git a/contracts/ERC1967Proxy.sol b/contracts/ERC1967Proxy.sol new file mode 100644 index 0000000..4435331 --- /dev/null +++ b/contracts/ERC1967Proxy.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +/* + * This file is only used to compile the proxy contract and generate ABI + */ diff --git a/contracts/IERC20Receiver.sol b/contracts/IERC20Receiver.sol new file mode 100644 index 0000000..07f8e54 --- /dev/null +++ b/contracts/IERC20Receiver.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title Interface for any contract that wants to support ERC20 transfers from NFT manager contracts + */ +interface IERC20Receiver +{ + //=============================================================// + // PUBLIC FUNCTIONS // + //=============================================================// + + /** + * Function called by NFT manager contracts when ERC20 tokens are transferred to + * payment address, in case it is a contract. + * It must return its Solidity selector to confirm the token transfer. + * + * @param token_ Token address + * @param amount_ Token amount + * @return Function selector, i.e. `IERC20Receiver.onERC20Received.selector` + */ + function onERC20Received( + IERC20 token_, + uint256 amount_ + ) external returns (bytes4); +} diff --git a/contracts/NftsAuction.sol b/contracts/NftsAuction.sol new file mode 100644 index 0000000..e21178f --- /dev/null +++ b/contracts/NftsAuction.sol @@ -0,0 +1,670 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "./NftsManagerBase.sol"; + + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title NFTs auction + * @notice It allows users to bid a specific ERC20 token amount (usually stable coins) for a ERC721 or ERC1155 NFT. + * The auctions are set by the contract owner. + */ +contract NftsAuction is + ReentrancyGuard, + NftsManagerBase +{ + //=============================================================// + // ENUMERATIVES // + //=============================================================// + + /// Auction states + enum AuctionStates { + INACTIVE, + ACTIVE, + COMPLETED + } + + //=============================================================// + // STRUCTURES // + //=============================================================// + + /// Structure for auctioning a token + struct Auction { + uint256 nftAmount; // Only used for ERC1155 (always 0 for ERC721) + address highestBidder; + IERC20 erc20Contract; + uint256 erc20StartPrice; + uint256 erc20MinimumBidIncrement; + uint256 erc20HighestBid; + uint256 startTime; + uint256 endTime; + uint256 extendTimeSec; + AuctionStates state; + } + + //=============================================================// + // ERRORS // + //=============================================================// + + /** + * Error raised if a token auction is already active for the `nftContract` and `nftId` + * @param nftContract NFT contract address + * @param nftId NFT ID + */ + error AuctionAlreadyActiveError( + address nftContract, + uint256 nftId + ); + + /** + * Error raised if a token auction is not active for the `nftContract` and `nftId` + * @param nftContract NFT contract address + * @param nftId NFT ID + */ + error AuctionNotActiveError( + address nftContract, + uint256 nftId + ); + + /** + * Error raised if a token auction is not expired for the `nftContract` and `nftId` + * @param nftContract NFT contract address + * @param nftId NFT ID + */ + error AuctionNotExpiredError( + address nftContract, + uint256 nftId + ); + + /** + * Error raised if `bidder` is not the auction winner + * @param bidder Bidder address + */ + error BidderNotWinnerError( + address bidder + ); + + //=============================================================// + // EVENTS // + //=============================================================// + + /** + * Event emitted when a token auction is created + * @param nftContract NFT contract address + * @param nftId NFT ID + * @param nftAmount_ NFT amount + * @param erc20Contract ERC20 contract address + * @param erc20StartPrice ERC20 start price + * @param erc20MinimumBidIncrement Minimum bid increment in ERC20 token + * @param startTime Start time + * @param durationSec Duration in seconds + * @param extendTimeSec Extend time in seconds + */ + event AuctionCreated( + address nftContract, + uint256 nftId, + uint256 nftAmount_, + IERC20 erc20Contract, + uint256 erc20StartPrice, + uint256 erc20MinimumBidIncrement, + uint256 startTime, + uint256 durationSec, + uint256 extendTimeSec + ); + + /** + * Event emitted when a token auction is removed + * @param nftContract NFT contract address + * @param nftId NFT ID + */ + event AuctionRemoved( + address nftContract, + uint256 nftId + ); + + /** + * Event emitted when a token auction is bid + * @param nftContract NFT contract address + * @param nftId NFT ID + * @param bidder Bidder address + * @param erc20Contract ERC20 contract address + * @param erc20BidAmount Bid amount in ERC20 token + */ + event AuctionBid( + address nftContract, + uint256 nftId, + address bidder, + IERC20 erc20Contract, + uint256 erc20BidAmount + ); + + /** + * Event emitted when a token auction is completed + * @param nftContract NFT contract address + * @param nftId NFT ID + * @param nftAmount NFT amount + * @param bidder Bidder address + * @param erc20Contract ERC20 contract address + * @param erc20Amount ERC20 amount + */ + event AuctionCompleted( + address nftContract, + uint256 nftId, + uint256 nftAmount, + address bidder, + IERC20 erc20Contract, + uint256 erc20Amount + ); + + //=============================================================// + // STORAGE // + //=============================================================// + + /// Mapping from token address and ID to auction data + mapping(address => mapping(uint256 => Auction)) public Auctions; + + //=============================================================// + // PUBLIC FUNCTIONS // + //=============================================================// + + /** + * Get if an auction is active + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @return True if active, false otherwise + */ + function isAuctionActive( + address nftContract_, + uint256 nftId_ + ) external view returns (bool) { + Auction storage auction = Auctions[nftContract_][nftId_]; + return __isAuctionActive(auction); + } + + /** + * Get if an auction is expired + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @return True if expired, false otherwise + */ + function isAuctionExpired( + address nftContract_, + uint256 nftId_ + ) external view returns (bool) { + Auction storage auction = Auctions[nftContract_][nftId_]; + return __isAuctionExpired(auction); + } + + /** + * Get if an auction is completed + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @return True if completed, false otherwise + */ + function isAuctionCompleted( + address nftContract_, + uint256 nftId_ + ) external view returns (bool) { + Auction storage auction = Auctions[nftContract_][nftId_]; + return __isAuctionCompleted(auction); + } + + /** + * Create a ERC721 token auction + * The NFT shall be owned by the contract + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param erc20Contract_ ERC20 contract address + * @param erc20StartPrice_ Starting price for the auction in ERC20 token + * @param erc20MinimumBidIncrement_ Minimum bid increment in ERC20 token + * @param durationSec_ Duration in seconds + * @param extendTimeSec_ Extend time in seconds + */ + function createERC721Auction( + IERC721 nftContract_, + uint256 nftId_, + IERC20 erc20Contract_, + uint256 erc20StartPrice_, + uint256 erc20MinimumBidIncrement_, + uint256 durationSec_, + uint256 extendTimeSec_ + ) public onlyOwner { + __createAuction( + address(nftContract_), + nftId_, + 0, + erc20Contract_, + erc20StartPrice_, + erc20MinimumBidIncrement_, + durationSec_, + extendTimeSec_ + ); + } + + /** + * Create a ERC1155 token auction + * The NFT shall be owned by the contract + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount + * @param erc20Contract_ ERC20 contract address + * @param erc20StartPrice_ Starting price for the auction in ERC20 token + * @param erc20MinimumBidIncrement_ Minimum bid increment in ERC20 token + * @param durationSec_ Duration in seconds + * @param extendTimeSec_ Extend time in seconds + */ + function createERC1155Auction( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20StartPrice_, + uint256 erc20MinimumBidIncrement_, + uint256 durationSec_, + uint256 extendTimeSec_ + ) + public + onlyOwner + notZeroAmount(nftAmount_) + { + __createAuction( + address(nftContract_), + nftId_, + nftAmount_, + erc20Contract_, + erc20StartPrice_, + erc20MinimumBidIncrement_, + durationSec_, + extendTimeSec_ + ); + } + + /** + * Remove a token auction + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function removeAuction( + address nftContract_, + uint256 nftId_ + ) public onlyOwner { + __removeAuction(nftContract_, nftId_); + } + + /** + * Withdraw ERC721 token to owner. + * The token auction shall not be active. In case it is, it shall be removed before calling the function. + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function withdrawERC721( + IERC721 nftContract_, + uint256 nftId_ + ) public onlyOwner { + __withdraw( + address(nftContract_), + nftId_, + 0 + ); + } + + /** + * Withdraw ERC1155 token to owner. + * The token auction shall not be active. In case it is, it shall be removed before calling the function. + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount + */ + function withdrawERC1155( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) + public + onlyOwner + notZeroAmount(nftAmount_) + { + __withdraw( + address(nftContract_), + nftId_, + nftAmount_ + ); + } + + /** + * Bid at a token auction. + * The bidder shall have at least `erc20BidAmount_` ERC20 token in the wallets to bid. + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param erc20BidAmount_ Bid amount in ERC20 token + */ + function bidAtAuction( + address nftContract_, + uint256 nftId_, + uint256 erc20BidAmount_ + ) public nonReentrant { + __bidAtAuction( + nftContract_, + nftId_, + erc20BidAmount_ + ); + } + + /** + * Complete a token auction + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function completeAuction( + address nftContract_, + uint256 nftId_ + ) public nonReentrant { + __completeAuction( + nftContract_, + nftId_ + ); + } + + //=============================================================// + // INTERNAL FUNCTIONS // + //=============================================================// + + /** + * Initialize the auction `auction_` + * @param auction_ Auction structure + * @param nftAmount_ NFT amount + * @param erc20Contract_ ERC20 contract address + * @param erc20StartPrice_ Starting price for the auction in ERC20 token + * @param erc20MinimumBidIncrement_ Minimum bid increment in ERC20 token + * @param durationSec_ Duration in seconds + * @param extendTimeSec_ Extend time in seconds + */ + function __initAuction( + Auction storage auction_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20StartPrice_, + uint256 erc20MinimumBidIncrement_, + uint256 durationSec_, + uint256 extendTimeSec_ + ) private { + auction_.highestBidder = address(0); + auction_.nftAmount = nftAmount_; + auction_.erc20Contract = erc20Contract_; + auction_.erc20StartPrice = erc20StartPrice_; + auction_.erc20MinimumBidIncrement = erc20MinimumBidIncrement_; + auction_.erc20HighestBid = erc20StartPrice_; + auction_.startTime = block.timestamp; + auction_.endTime = block.timestamp + durationSec_; + auction_.extendTimeSec = extendTimeSec_; + auction_.state = AuctionStates.ACTIVE; + } + + /** + * Get if the auction `auction_` is active + * @param auction_ Auction structure + * @return True if active, false otherwise + */ + function __isAuctionActive( + Auction storage auction_ + ) private view returns (bool) { + return (auction_.state == AuctionStates.ACTIVE) && (block.timestamp <= auction_.endTime); + } + + /** + * Get if the auction `auction_` is expired + * @param auction_ Auction structure + * @return True if expired, false otherwise + */ + function __isAuctionExpired( + Auction storage auction_ + ) private view returns (bool) { + return (auction_.state == AuctionStates.ACTIVE) && (block.timestamp > auction_.endTime); + } + + /** + * Get if the auction `auction_` is completed + * @param auction_ Auction structure + * @return True if completed, false otherwise + */ + function __isAuctionCompleted( + Auction storage auction_ + ) private view returns (bool) { + return (auction_.state == AuctionStates.COMPLETED) && (block.timestamp > auction_.endTime); + } + + /** + * Create a ERC721 token auction + * The NFT shall be owned by the contract + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount + * @param erc20Contract_ ERC20 contract address + * @param erc20StartPrice_ Starting price for the auction in ERC20 token + * @param erc20MinimumBidIncrement_ Minimum bid increment in ERC20 token + * @param durationSec_ Duration in seconds + * @param extendTimeSec_ Extend time in seconds + */ + function __createAuction( + address nftContract_, + uint256 nftId_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20StartPrice_, + uint256 erc20MinimumBidIncrement_, + uint256 durationSec_, + uint256 extendTimeSec_ + ) + private + notNullAddress(address(erc20Contract_)) + notZeroAmount(erc20MinimumBidIncrement_) + notZeroAmount(durationSec_) + { + if (nftAmount_ == 0) { + _validateERC721(IERC721(nftContract_), nftId_); + } + else { + _validateERC1155(IERC1155(nftContract_), nftId_, nftAmount_); + } + + Auction storage auction = Auctions[nftContract_][nftId_]; + if (__isAuctionActive(auction)) { + revert AuctionAlreadyActiveError(nftContract_, nftId_); + } + + __initAuction( + auction, + nftAmount_, + erc20Contract_, + erc20StartPrice_, + erc20MinimumBidIncrement_, + durationSec_, + extendTimeSec_ + ); + + emit AuctionCreated( + nftContract_, + nftId_, + nftAmount_, + erc20Contract_, + erc20StartPrice_, + erc20MinimumBidIncrement_, + block.timestamp, + durationSec_, + extendTimeSec_ + ); + } + + /** + * Remove a token auction + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function __removeAuction( + address nftContract_, + uint256 nftId_ + ) + private + notNullAddress(address(nftContract_)) + { + Auction storage auction = Auctions[nftContract_][nftId_]; + if (!__isAuctionActive(auction) && !__isAuctionExpired(auction)) { + revert AuctionNotActiveError(nftContract_, nftId_); + } + + auction.state = AuctionStates.INACTIVE; + + emit AuctionRemoved(nftContract_, nftId_); + } + + /** + * Withdraw token to owner + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount (ignored for ERC721) + */ + function __withdraw( + address nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) + private + notNullAddress(nftContract_) + { + address target = owner(); + + Auction storage auction = Auctions[nftContract_][nftId_]; + if (__isAuctionActive(auction)) { + if (nftAmount_ == 0) { + revert WithdrawError(nftContract_, nftId_); + } + uint256 withdrawable_amount = IERC1155(nftContract_).balanceOf(address(this), nftId_) - auction.nftAmount; + if (nftAmount_ > withdrawable_amount) { + revert WithdrawError(nftContract_, nftId_); + } + } + + if (nftAmount_ == 0) { + _withdrawERC721( + target, + IERC721(nftContract_), + nftId_ + ); + } + else { + _withdrawERC1155( + target, + IERC1155(nftContract_), + nftId_, + nftAmount_ + ); + } + } + + /** + * Bid at a token auction + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param erc20BidAmount_ Bid amount in ERC20 token + */ + function __bidAtAuction( + address nftContract_, + uint256 nftId_, + uint256 erc20BidAmount_ + ) + private + notNullAddress(nftContract_) + { + Auction storage auction = Auctions[nftContract_][nftId_]; + if (!__isAuctionActive(auction)) { + revert AuctionNotActiveError(nftContract_, nftId_); + } + if (erc20BidAmount_ < (auction.erc20HighestBid + auction.erc20MinimumBidIncrement)) { + revert AmountError(); + } + + address bidder = _msgSender(); + if (auction.erc20Contract.balanceOf(bidder) < erc20BidAmount_) { + revert AmountError(); + } + + auction.highestBidder = bidder; + auction.erc20HighestBid = erc20BidAmount_; + + // Extend auction time if needed + if ((auction.endTime - block.timestamp) < auction.extendTimeSec) { + auction.endTime += auction.extendTimeSec; + } + + emit AuctionBid( + nftContract_, + nftId_, + bidder, + auction.erc20Contract, + erc20BidAmount_ + ); + } + + /** + * Complete auction + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function __completeAuction( + address nftContract_, + uint256 nftId_ + ) + private + notNullAddress(nftContract_) + { + Auction storage auction = Auctions[nftContract_][nftId_]; + if (!__isAuctionExpired(auction)) { + revert AuctionNotExpiredError(nftContract_, nftId_); + } + + address bidder = _msgSender(); + if (bidder != auction.highestBidder) { + revert BidderNotWinnerError(bidder); + } + + auction.state = AuctionStates.COMPLETED; + + if (auction.nftAmount == 0) { + _transferERC721InExchangeOfERC20( + bidder, + IERC721(nftContract_), + nftId_, + auction.erc20Contract, + auction.erc20HighestBid + ); + } + else { + _transferERC1155InExchangeOfERC20( + bidder, + IERC1155(nftContract_), + nftId_, + auction.nftAmount, + auction.erc20Contract, + auction.erc20HighestBid + ); + } + + emit AuctionCompleted( + nftContract_, + nftId_, + auction.nftAmount, + bidder, + auction.erc20Contract, + auction.erc20HighestBid + ); + } +} diff --git a/contracts/NftsManagerBase.sol b/contracts/NftsManagerBase.sol new file mode 100644 index 0000000..a173c3e --- /dev/null +++ b/contracts/NftsManagerBase.sol @@ -0,0 +1,477 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts/utils/Address.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "./IERC20Receiver.sol"; + + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title Base constract for NFT managers + */ +abstract contract NftsManagerBase is + Initializable, + OwnableUpgradeable, + UUPSUpgradeable, + IERC721Receiver, + IERC1155Receiver +{ + using Address for address; + using SafeERC20 for IERC20; + + //=============================================================// + // ERRORS // + //=============================================================// + + /** + * Error raised in case of an amount error + */ + error AmountError(); + + /** + * Error raised in case of a NFT error + * @param nftContract NFT contract address + * @param nftId NFT ID + */ + error NftError( + address nftContract, + uint256 nftId + ); + + /** + * Error raised in case of a null address + */ + error NullAddressError(); + + /** + * Error raised in case of a withdraw error + * @param nftContract NFT contract address + * @param nftId NFT ID + */ + error WithdrawError( + address nftContract, + uint256 nftId + ); + + /** + * Error raised in case the onERC20Received function returns the wrong value + */ + error IERC20ReceiverRetValError(); + + /** + * Error raised in case the onERC20Received function is not implemented + */ + error IERC20ReceiverNotImplError(); + + //=============================================================// + // EVENTS // + //=============================================================// + + /** + * Event emitted when the payment ERC20 address is changed + * @param oldAddress Old address + * @param newAddress New address + */ + event PaymentERC20AddressChanged( + address oldAddress, + address newAddress + ); + + /** + * Event emitted when a ERC721 token is withdrawn + * @param target Target address + * @param nftContract NFT contract address + * @param nftId NFT ID + */ + event ERC721Withdrawn( + address target, + IERC721 nftContract, + uint256 nftId + ); + + /** + * Event emitted when a ERC1155 token is withdrawn + * @param target Target address + * @param nftContract NFT contract address + * @param nftId NFT ID + * @param nftAmount NFT amount + */ + event ERC1155Withdrawn( + address target, + IERC1155 nftContract, + uint256 nftId, + uint256 nftAmount + ); + + //=============================================================// + // MODIFIERS // + //=============================================================// + + /** + * Modifier to make a function callable only if the address `address_` is not null + * @param address_ Address + */ + modifier notNullAddress( + address address_ + ) { + if (address_ == address(0)) { + revert NullAddressError(); + } + _; + } + + /** + * Modifier to make a function callable only if the amount `amount_` is not zero + * @param amount_ Amount + */ + modifier notZeroAmount( + uint256 amount_ + ) { + if (amount_ == 0) { + revert AmountError(); + } + _; + } + + //=============================================================// + // STORAGE // + //=============================================================// + + /// Wallet address where ERC20 tokens will be transferred + address public paymentERC20Address; + + //=============================================================// + // CONSTRUCTOR // + //=============================================================// + + /** + * Constructor + * @dev Disable initializer for implementation contract + */ + constructor() { + _disableInitializers(); + } + + //=============================================================// + // PUBLIC FUNCTIONS // + //=============================================================// + + /** + * Initialize + * @param paymentERC20Address_ ERC20 payment address + */ + function init( + address paymentERC20Address_ + ) public initializer { + __Ownable_init(); + __setPaymentERC20Address(paymentERC20Address_); + } + + /** + * Set payment ERC20 address + * @param paymentERC20Address_ ERC20 payment address + */ + function setPaymentERC20Address( + address paymentERC20Address_ + ) public onlyOwner { + __setPaymentERC20Address(paymentERC20Address_); + } + + //=============================================================// + // INTERNAL FUNCTIONS // + //=============================================================// + + /** + * Revert if the ERC721 token is not valid + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function _validateERC721( + IERC721 nftContract_, + uint256 nftId_ + ) + internal + view + notNullAddress(address(nftContract_)) + { + // NFT shall be minted + try nftContract_.ownerOf(nftId_) returns (address) { + } catch { + revert NftError( + address(nftContract_), + nftId_ + ); + } + + // NFT shall be owned by the contract + if (nftContract_.ownerOf(nftId_) != address(this)) { + revert NftError( + address(nftContract_), + nftId_ + ); + } + } + + /** + * Revert if the ERC1155 token is not valid + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount + */ + function _validateERC1155( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) + internal + view + notNullAddress(address(nftContract_)) + { + if (nftContract_.balanceOf(address(this), nftId_) < nftAmount_) { + revert NftError( + address(nftContract_), + nftId_ + ); + } + } + + /** + * Withdraw ERC721 token to `target_` + * @param target_ Target address + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function _withdrawERC721( + address target_, + IERC721 nftContract_, + uint256 nftId_ + ) + internal + notNullAddress(address(nftContract_)) + { + nftContract_.safeTransferFrom( + nftContract_.ownerOf(nftId_), + target_, + nftId_ + ); + + emit ERC721Withdrawn( + target_, + nftContract_, + nftId_ + ); + } + + /** + * Withdraw ERC1155 token to `target_` + * @param target_ Target address + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount + */ + function _withdrawERC1155( + address target_, + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) + internal + notNullAddress(address(nftContract_)) + notZeroAmount(nftAmount_) + { + nftContract_.safeTransferFrom( + address(this), + target_, + nftId_, + nftAmount_, + "" + ); + + emit ERC1155Withdrawn( + target_, + nftContract_, + nftId_, + nftAmount_ + ); + } + + /** + * Transfer a ERC721 token in exchange of ERC20 token + * @param user_ User address + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param erc20Contract_ ERC20 contract address + * @param erc20Amount_ ERC20 amount to pay for the redeem + * @dev The ERC20 token shall be approved by the target address + * The ERC721 token shall be owned by the contract + */ + function _transferERC721InExchangeOfERC20( + address user_, + IERC721 nftContract_, + uint256 nftId_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) internal { + __transferERC20( + user_, + erc20Contract_, + erc20Amount_ + ); + nftContract_.safeTransferFrom( + address(this), + user_, + nftId_ + ); + } + + /** + * Transfer a ERC1155 token in exchange of ERC20 token + * @param user_ User address + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount + * @param erc20Contract_ ERC20 contract address + * @param erc20Amount_ ERC20 amount to pay for the redeem + * @dev The ERC20 token shall be approved by the target address + * The ERC1155 token shall be owned by the contract + */ + function _transferERC1155InExchangeOfERC20( + address user_, + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) internal { + __transferERC20( + user_, + erc20Contract_, + erc20Amount_ + ); + nftContract_.safeTransferFrom( + address(this), + user_, + nftId_, + nftAmount_, + "" + ); + } + + //=============================================================// + // PRIVATE FUNCTIONS // + //=============================================================// + + /** + * Set payment ERC20 address + * @param paymentERC20Address_ ERC20 payment address + */ + function __setPaymentERC20Address( + address paymentERC20Address_ + ) private notNullAddress(paymentERC20Address_) { + address old_address = paymentERC20Address; + paymentERC20Address = paymentERC20Address_; + + emit PaymentERC20AddressChanged(old_address, paymentERC20Address); + } + + + /** + * Transfer ERC20 token + * @param user_ User address + * @param erc20Contract_ ERC20 contract address + * @param erc20Amount_ ERC20 amount to pay for the redeem + */ + function __transferERC20( + address user_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) private { + erc20Contract_.safeTransferFrom( + user_, + paymentERC20Address, + erc20Amount_ + ); + + if (paymentERC20Address.isContract()) { + try IERC20Receiver(paymentERC20Address).onERC20Received(erc20Contract_, erc20Amount_) returns (bytes4 ret) { + if (ret != IERC20Receiver.onERC20Received.selector) { + revert IERC20ReceiverRetValError(); + } + } catch { + revert IERC20ReceiverNotImplError(); + } + } + } + + //=============================================================// + // OVERRIDDEN FUNCTIONS // + //=============================================================// + + /** + * Restrict upgrade to owner + * See {UUPSUpgradeable-_authorizeUpgrade} + */ + function _authorizeUpgrade( + address newImplementation_ + ) internal override onlyOwner + {} + + /** + * See {IERC721Receiver-onERC721Received} + */ + function onERC721Received( + address operator_, + address from_, + uint256 tokenId_, + bytes calldata data_ + ) external override returns (bytes4) { + return IERC721Receiver.onERC721Received.selector; + } + + /** + * See {IERC1155Receiver-onERC1155Received} + */ + function onERC1155Received( + address operator_, + address from_, + uint256 id_, + uint256 value_, + bytes calldata data_ + ) external override returns (bytes4) { + return IERC1155Receiver.onERC1155Received.selector; + } + + /** + * See {IERC1155Receiver-onERC1155BatchReceived} + */ + function onERC1155BatchReceived( + address operator_, + address from_, + uint256[] calldata ids_, + uint256[] calldata values_, + bytes calldata data_ + ) external override returns (bytes4) { + return IERC1155Receiver.onERC1155BatchReceived.selector; + } + + /** + * See {IERC165-supportsInterface} + */ + function supportsInterface( + bytes4 interfaceId_ + ) public view virtual override returns (bool) { + return false; + } +} diff --git a/contracts/NftsRedeemer.sol b/contracts/NftsRedeemer.sol new file mode 100644 index 0000000..972120a --- /dev/null +++ b/contracts/NftsRedeemer.sol @@ -0,0 +1,467 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "./NftsManagerBase.sol"; + + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title NFTs redeemer + * @notice It allows to reserve a ERC721 or ERC1155 token for a specific wallet, which can redeem it by paying in ERC20 tokens (e.g. stablecoins). + * The NFTs to be redeemed are set by the contract owner. + */ +contract NftsRedeemer is + ReentrancyGuard, + NftsManagerBase +{ + //=============================================================// + // STRUCTURES // + //=============================================================// + + /// Structure for redeem data + struct Redeem { + address nftContract; + uint256 nftId; + uint256 nftAmount; // Only used for ERC1155 (always 0 for ERC721) + IERC20 erc20Contract; + uint256 erc20Amount; + bool isActive; + } + + //=============================================================// + // ERRORS // + //=============================================================// + + /** + * Error raised if a token redeem is already created for the `reedemer` + * @param reedemer Redeemer address + */ + error RedeemAlreadyCreatedError( + address reedemer + ); + + /** + * Error raised if a token redeem is not created for the `reedemer` + * @param reedemer Redeemer address + */ + error RedeemNotCreatedError( + address reedemer + ); + + //=============================================================// + // EVENTS // + //=============================================================// + + /** + * Event emitted when a redeem is created + * @param redeemer Redeemer address + * @param nftContract NFT contract address + * @param nftId NFT ID + * @param nftAmount NFT amount + * @param erc20Contract ERC20 contract address + * @param erc20Amount ERC20 amount to pay for the redeem + */ + event RedeemCreated( + address redeemer, + address nftContract, + uint256 nftId, + uint256 nftAmount, + IERC20 erc20Contract, + uint256 erc20Amount + ); + + /** + * Event emitted when a redeem is removed + * @param redeemer Redeemer address + * @param nftContract NFT contract address + * @param nftId NFT ID + */ + event RedeemRemoved( + address redeemer, + address nftContract, + uint256 nftId + ); + + /** + * Event emitted when a redeem is completed + * @param redeemer Redeemer address + * @param nftContract NFT contract address + * @param nftId NFT ID + * @param nftAmount NFT amount + * @param erc20Contract ERC20 contract address + * @param erc20Amount ERC20 amount to pay for the redeem + */ + event RedeemCompleted( + address redeemer, + address nftContract, + uint256 nftId, + uint256 nftAmount, + IERC20 erc20Contract, + uint256 erc20Amount + ); + + //=============================================================// + // STORAGE // + //=============================================================// + + /// Mapping from wallet address to redeem data + mapping(address => Redeem) public Redeems; + /// Mapping from token address and ID to redeemer address + mapping(address => mapping(uint256 => address)) public Redeemers; + + //=============================================================// + // PUBLIC FUNCTIONS // + //=============================================================// + + /** + * Get if a redeem is active + * @param redeemer_ Redeemer address + * @return True if active, false otherwise + */ + function isRedeemActive( + address redeemer_ + ) external view returns (bool) { + Redeem storage redeem = Redeems[redeemer_]; + return redeem.isActive; + } + + /** + * Get if a redeem is active + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @return True if active, false otherwise + */ + function isRedeemActive( + address nftContract_, + uint256 nftId_ + ) external view returns (bool) { + Redeem storage redeem = Redeems[Redeemers[nftContract_][nftId_]]; + return redeem.isActive; + } + + /** + * Create a ERC721 token redeem + * The NFT shall be owned by the contract + * @param redeemer_ Redeemer address + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param erc20Contract_ ERC20 contract address + * @param erc20Amount_ ERC20 amount to pay for the redeem + */ + function createERC721Redeem( + address redeemer_, + IERC721 nftContract_, + uint256 nftId_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) public onlyOwner { + __createRedeem( + redeemer_, + address(nftContract_), + nftId_, + 0, + erc20Contract_, + erc20Amount_ + ); + } + + /** + * Create a ERC1155 token redeem + * The NFT shall be owned by the contract + * @param redeemer_ Redeemer address + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount + * @param erc20Contract_ ERC20 contract address + * @param erc20Amount_ ERC20 amount to pay for the redeem + */ + function createERC1155Redeem( + address redeemer_, + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) + public + onlyOwner + notZeroAmount(nftAmount_) + { + __createRedeem( + redeemer_, + address(nftContract_), + nftId_, + nftAmount_, + erc20Contract_, + erc20Amount_ + ); + } + + /** + * Remove a token redeem + * @param redeemer_ Redeemer address + */ + function removeRedeem( + address redeemer_ + ) public onlyOwner { + __removeRedeem(redeemer_); + } + + /** + * Withdraw ERC721 token to owner. + * The token shall not be on redeem. In case it is, it shall be removed before calling the function. + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function withdrawERC721( + IERC721 nftContract_, + uint256 nftId_ + ) public onlyOwner { + __withdraw( + address(nftContract_), + nftId_, + 0 + ); + } + + /** + * Withdraw ERC1155 token to owner. + * The token shall not be on redeem. In case it is, it shall be removed before calling the function. + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount + */ + function withdrawERC1155( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) + public + onlyOwner + notZeroAmount(nftAmount_) + { + __withdraw( + address(nftContract_), + nftId_, + nftAmount_ + ); + } + + /** + * Redeem a token + */ + function redeemToken() public nonReentrant { + __redeemToken(); + } + + //=============================================================// + // INTERNAL FUNCTIONS // + //=============================================================// + + /** + * Initialize the redeem `redeem_` + * @param redeem_ Redeem structure + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount + * @param erc20Contract_ ERC20 contract address + * @param erc20Amount_ ERC20 amount + */ + function __initRedeem( + Redeem storage redeem_, + address nftContract_, + uint256 nftId_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) private { + redeem_.nftContract = nftContract_; + redeem_.nftId = nftId_; + redeem_.nftAmount = nftAmount_; + redeem_.erc20Contract = erc20Contract_; + redeem_.erc20Amount = erc20Amount_; + redeem_.isActive = true; + } + + /** + * Create a token redeem + * @param redeemer_ Redeemer address + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount + * @param erc20Contract_ ERC20 contract address + * @param erc20Amount_ ERC20 amount to pay for the redeem + */ + function __createRedeem( + address redeemer_, + address nftContract_, + uint256 nftId_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) + private + notNullAddress(redeemer_) + notNullAddress(address(erc20Contract_)) + { + if (nftAmount_ == 0) { + _validateERC721(IERC721(nftContract_), nftId_); + } + else { + _validateERC1155(IERC1155(nftContract_), nftId_, nftAmount_); + } + + address current_redeemer = Redeemers[nftContract_][nftId_]; + if (current_redeemer != address(0)) { + revert RedeemAlreadyCreatedError(current_redeemer); + } + + Redeem storage redeem = Redeems[redeemer_]; + if (redeem.isActive) { + revert RedeemAlreadyCreatedError(redeemer_); + } + + Redeemers[nftContract_][nftId_] = redeemer_; + + __initRedeem( + Redeems[redeemer_], + nftContract_, + nftId_, + nftAmount_, + erc20Contract_, + erc20Amount_ + ); + + emit RedeemCreated( + redeemer_, + nftContract_, + nftId_, + nftAmount_, + erc20Contract_, + erc20Amount_ + ); + } + + /** + * Remove a token redeem + * @param redeemer_ Redeemer address + */ + function __removeRedeem( + address redeemer_ + ) + private + notNullAddress(redeemer_) + { + Redeem storage redeem = Redeems[redeemer_]; + if (Redeemers[redeem.nftContract][redeem.nftId] == address(0)) { + revert RedeemNotCreatedError(redeemer_); + } + + Redeemers[redeem.nftContract][redeem.nftId] = address(0); + redeem.isActive = false; + + emit RedeemRemoved( + redeemer_, + redeem.nftContract, + redeem.nftId + ); + } + + /** + * Withdraw token to owner + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount (ignored for ERC721) + */ + function __withdraw( + address nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) + private + notNullAddress(nftContract_) + { + address target = owner(); + + address redeemer = Redeemers[nftContract_][nftId_]; + if (redeemer != address(0)) { + Redeem storage redeem = Redeems[redeemer]; + + if (nftAmount_ == 0) { + revert WithdrawError(nftContract_, nftId_); + } + uint256 withdrawable_amount = IERC1155(nftContract_).balanceOf(address(this), nftId_) - redeem.nftAmount; + if (nftAmount_ > withdrawable_amount) { + revert WithdrawError(nftContract_, nftId_); + } + } + + if (nftAmount_ == 0) { + _withdrawERC721( + target, + IERC721(nftContract_), + nftId_ + ); + } + else { + _withdrawERC1155( + target, + IERC1155(nftContract_), + nftId_, + nftAmount_ + ); + } + } + + /** + * Redeem a token + */ + function __redeemToken() private { + address redeemer = _msgSender(); + + Redeem storage redeem = Redeems[redeemer]; + if (Redeemers[redeem.nftContract][redeem.nftId] == address(0)) { + revert RedeemNotCreatedError(redeemer); + } + + Redeemers[redeem.nftContract][redeem.nftId] = address(0); + redeem.isActive = false; + + if (redeem.nftAmount == 0) { + _transferERC721InExchangeOfERC20( + redeemer, + IERC721(redeem.nftContract), + redeem.nftId, + redeem.erc20Contract, + redeem.erc20Amount + ); + } + else { + _transferERC1155InExchangeOfERC20( + redeemer, + IERC1155(redeem.nftContract), + redeem.nftId, + redeem.nftAmount, + redeem.erc20Contract, + redeem.erc20Amount + ); + } + + emit RedeemCompleted( + redeemer, + redeem.nftContract, + redeem.nftId, + redeem.nftAmount, + redeem.erc20Contract, + redeem.erc20Amount + ); + } +} diff --git a/contracts/NftsSeller.sol b/contracts/NftsSeller.sol new file mode 100644 index 0000000..4a8eb71 --- /dev/null +++ b/contracts/NftsSeller.sol @@ -0,0 +1,509 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "./NftsManagerBase.sol"; + + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title NFTs seller + * @notice It allows users to buy a ERC721 or ERC1155 NFT for a specific ERC20 token amount (e.g. stable coins). + * The NFTs to be sold are set by the contract owner. + */ +contract NftsSeller is + ReentrancyGuard, + NftsManagerBase +{ + //=============================================================// + // STRUCTURES // + //=============================================================// + + /// Structure for sale data + struct Sale { + uint256 nftAmount; // Only used for ERC1155 (always 0 for ERC721) + IERC20 erc20Contract; + uint256 erc20Amount; + bool isActive; + } + + //=============================================================// + // ERRORS // + //=============================================================// + + /** + * Error raised if a token sale is already created for the `nftContract` and `nftId` + * @param nftContract NFT contract address + * @param nftId NFT ID + */ + error SaleAlreadyCreatedError( + address nftContract, + uint256 nftId + ); + + /** + * Error raised if a token sale is not created for the `nftContract` and `nftId` + * @param nftContract NFT contract address + * @param nftId NFT ID + */ + error SaleNotCreatedError( + address nftContract, + uint256 nftId + ); + + //=============================================================// + // EVENTS // + //=============================================================// + + /** + * Event emitted when a sale is created + * @param nftContract NFT contract address + * @param nftId NFT ID + * @param nftAmount NFT amount + * @param erc20Contract ERC20 contract address + * @param erc20Amount ERC20 amount to pay for the sale + */ + event SaleCreated( + address nftContract, + uint256 nftId, + uint256 nftAmount, + IERC20 erc20Contract, + uint256 erc20Amount + ); + + /** + * Event emitted when a sale is removed + * @param nftContract NFT contract address + * @param nftId NFT ID + */ + event SaleRemoved( + address nftContract, + uint256 nftId + ); + + /** + * Event emitted when a sale is completed + * @param buyer Buyer address + * @param nftContract NFT contract address + * @param nftId NFT ID + * @param nftAmount NFT amount + * @param erc20Contract ERC20 contract address + * @param erc20Amount ERC20 amount to pay for the sale + */ + event SaleCompleted( + address buyer, + address nftContract, + uint256 nftId, + uint256 nftAmount, + IERC20 erc20Contract, + uint256 erc20Amount + ); + + //=============================================================// + // STORAGE // + //=============================================================// + + /// Mapping from token address and ID to sale data + mapping(address => mapping(uint256 => Sale)) public Sales; + + //=============================================================// + // PUBLIC FUNCTIONS // + //=============================================================// + + /** + * Get if a sale is active + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @return True if active, false otherwise + */ + function isSaleActive( + address nftContract_, + uint256 nftId_ + ) external view returns (bool) { + Sale storage sale = Sales[nftContract_][nftId_]; + return sale.isActive; + } + + /** + * Create a ERC721 token sale + * The token shall be owned by the contract + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param erc20Contract_ ERC20 contract address + * @param erc20Amount_ Price of the ERC721 token in ERC20 token + */ + function createERC721Sale( + IERC721 nftContract_, + uint256 nftId_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) public onlyOwner { + __createSale( + address(nftContract_), + nftId_, + 0, + erc20Contract_, + erc20Amount_ + ); + } + + /** + * Create a ERC1155 token sale + * The token shall be owned by the contract + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount + * @param erc20Contract_ ERC20 contract address + * @param erc20Amount_ ERC20 amount + */ + function createERC1155Sale( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) + public + onlyOwner + notZeroAmount(nftAmount_) + { + __createSale( + address(nftContract_), + nftId_, + nftAmount_, + erc20Contract_, + erc20Amount_ + ); + } + + /** + * Remove a token sale + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function removeSale( + address nftContract_, + uint256 nftId_ + ) public onlyOwner { + __removeSale(nftContract_, nftId_); + } + + /** + * Withdraw a ERC721 token to owner. + * The token shall not be on sale. In case it is, it shall be removed before calling the function. + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function withdrawERC721( + IERC721 nftContract_, + uint256 nftId_ + ) public onlyOwner { + __withdraw( + address(nftContract_), + nftId_, + 0 + ); + } + + /** + * Withdraw a ERC1155 token to owner. + * The token shall not be on sale. In case it is, it shall be removed before calling the function. + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount (ignored for ERC721) + */ + function withdrawERC1155( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) + public + onlyOwner + notZeroAmount(nftAmount_) + { + __withdraw( + address(nftContract_), + nftId_, + nftAmount_ + ); + } + + /** + * Buy a ERC721 token + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function buyERC721( + IERC721 nftContract_, + uint256 nftId_ + ) public nonReentrant { + __buy( + address(nftContract_), + nftId_, + 0 + ); + } + + /** + * Buy a ERC1155 token + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount (ignored for ERC721) + */ + function buyERC1155( + IERC1155 nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) + public + nonReentrant + notZeroAmount(nftAmount_) + { + __buy( + address(nftContract_), + nftId_, + nftAmount_ + ); + } + + //=============================================================// + // INTERNAL FUNCTIONS // + //=============================================================// + + /** + * Initialize the sale `sale_` + * @param sale_ Sale structure + * @param nftAmount_ NFT amount + * @param erc20Contract_ ERC20 contract address + * @param erc20Amount_ ERC20 amount + */ + function __initSale( + Sale storage sale_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) private { + sale_.nftAmount = nftAmount_; + sale_.erc20Contract = erc20Contract_; + sale_.erc20Amount = erc20Amount_; + sale_.isActive = true; + } + + /** + * Create a token sale + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount + * @param erc20Contract_ ERC20 contract address + * @param erc20Amount_ ERC20 amount + */ + function __createSale( + address nftContract_, + uint256 nftId_, + uint256 nftAmount_, + IERC20 erc20Contract_, + uint256 erc20Amount_ + ) + private + notNullAddress(address(erc20Contract_)) + { + if (nftAmount_ == 0) { + _validateERC721(IERC721(nftContract_), nftId_); + } + else { + _validateERC1155(IERC1155(nftContract_), nftId_, nftAmount_); + } + + Sale storage sale = Sales[nftContract_][nftId_]; + if (sale.isActive) { + revert SaleAlreadyCreatedError(nftContract_, nftId_); + } + + __initSale( + sale, + nftAmount_, + erc20Contract_, + erc20Amount_ + ); + + emit SaleCreated( + nftContract_, + nftId_, + nftAmount_, + erc20Contract_, + erc20Amount_ + ); + } + + /** + * Remove a token sale + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function __removeSale( + address nftContract_, + uint256 nftId_ + ) + private + notNullAddress(address(nftContract_)) + { + Sale storage sale = Sales[nftContract_][nftId_]; + if (!sale.isActive) { + revert SaleNotCreatedError(nftContract_, nftId_); + } + + sale.isActive = false; + + emit SaleRemoved(nftContract_, nftId_); + } + + /** + * Withdraw token to owner + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount (ignored for ERC721) + */ + function __withdraw( + address nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) + private + notNullAddress(nftContract_) + { + address target = owner(); + + Sale storage sale = Sales[nftContract_][nftId_]; + if (sale.isActive) { + if (nftAmount_ == 0) { + revert WithdrawError(nftContract_, nftId_); + } + uint256 withdrawable_amount = IERC1155(nftContract_).balanceOf(address(this), nftId_) - sale.nftAmount; + if (nftAmount_ > withdrawable_amount) { + revert WithdrawError(nftContract_, nftId_); + } + } + + if (nftAmount_ == 0) { + _withdrawERC721( + target, + IERC721(nftContract_), + nftId_ + ); + } + else { + _withdrawERC1155( + target, + IERC1155(nftContract_), + nftId_, + nftAmount_ + ); + } + } + + /** + * Buy a token + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + * @param nftAmount_ NFT amount (ignored for ERC721) + */ + function __buy( + address nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) + private + notNullAddress(nftContract_) + { + Sale storage sale = Sales[nftContract_][nftId_]; + if (!sale.isActive) { + revert SaleNotCreatedError(nftContract_, nftId_); + } + + if (sale.nftAmount == 0) { + __buyERC721( + sale, + nftContract_, + nftId_ + ); + } + else { + __buyERC1155( + sale, + nftContract_, + nftId_, + nftAmount_ + ); + } + + emit SaleCompleted( + _msgSender(), + nftContract_, + nftId_, + nftAmount_, + sale.erc20Contract, + sale.erc20Amount + ); + } + + /** + * Buy a ERC721 token + * @param sale_ Sale structure + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function __buyERC721( + Sale storage sale_, + address nftContract_, + uint256 nftId_ + ) private { + sale_.isActive = false; + + _transferERC721InExchangeOfERC20( + _msgSender(), + IERC721(nftContract_), + nftId_, + sale_.erc20Contract, + sale_.erc20Amount + ); + } + + /** + * Buy a ERC1155 token + * @param sale_ Sale structure + * @param nftContract_ NFT contract address + * @param nftId_ NFT ID + */ + function __buyERC1155( + Sale storage sale_, + address nftContract_, + uint256 nftId_, + uint256 nftAmount_ + ) private { + if (nftAmount_ > sale_.nftAmount) { + revert AmountError(); + } + + // Reset if no more token left + sale_.nftAmount -= nftAmount_; + if (sale_.nftAmount == 0) { + sale_.isActive = false; + } + + _transferERC1155InExchangeOfERC20( + _msgSender(), + IERC1155(nftContract_), + nftId_, + nftAmount_, + sale_.erc20Contract, + sale_.erc20Amount * nftAmount_ + ); + } +} diff --git a/contracts/test/ERC20FixedSupply.sol b/contracts/test/ERC20FixedSupply.sol new file mode 100644 index 0000000..3e4c51c --- /dev/null +++ b/contracts/test/ERC20FixedSupply.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title Extension of {ERC20} that allows specifying an initial supply + */ +abstract contract ERC20FixedSupply is + ERC20 +{ + //=============================================================// + // ERRORS // + //=============================================================// + + /** + * Error raised if initial supply is not valid + */ + error InitialSupplyError(); + + //=============================================================// + // CONSTRUCTOR // + //=============================================================// + + /** + * Constructor + * + * @param name_ Token name + * @param symbol_ Token symbol + * @param initialSupply_ Initial supply + */ + constructor( + string memory name_, + string memory symbol_, + uint256 initialSupply_ + ) + ERC20(name_, symbol_) + { + if (initialSupply_ == 0) { + revert InitialSupplyError(); + } + _mint(_msgSender(), initialSupply_); + } +} diff --git a/contracts/test/MockERC1155Token.sol b/contracts/test/MockERC1155Token.sol new file mode 100644 index 0000000..b7b78b0 --- /dev/null +++ b/contracts/test/MockERC1155Token.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; + + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title Mock ERC1155 token + */ +contract MockERC1155Token is + ERC1155 +{ + //=============================================================// + // CONSTANTS // + //=============================================================// + + // NFT name + string constant private NFT_NAME = "Mock ERC1155 Token"; + // NFT symbol + string constant private NFT_SYMBOL = "MT"; + + //=============================================================// + // STORAGE // + //=============================================================// + + /// Contract name + string public name; + /// Contract symbol + string public symbol; + + //=============================================================// + // CONSTRUCTOR // + //=============================================================// + + /** + * Constructor + */ + constructor() + ERC1155("") + { + name = NFT_NAME; + symbol = NFT_SYMBOL; + } + + //=============================================================// + // FUNCTIONS // + //=============================================================// + + /** + * Mint `amount_` of token `id_` to `to_` + * @param to_ Receiver address + * @param id_ Token ID + * @param amount_ Token amount + */ + function mint( + address to_, + uint256 id_, + uint256 amount_ + ) public { + _mint(to_, id_, amount_, ""); + } +} diff --git a/contracts/test/MockERC20Receiver.sol b/contracts/test/MockERC20Receiver.sol new file mode 100644 index 0000000..7d65a6e --- /dev/null +++ b/contracts/test/MockERC20Receiver.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "../IERC20Receiver.sol"; + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title Mock ERC20 receiver + */ +contract MockERC20Receiver is + IERC20Receiver +{ + //=============================================================// + // STORAGE // + //=============================================================// + + /// Received flag + bool public received; + + //=============================================================// + // FUNCTIONS // + //=============================================================// + + /** + * Function called by the NFT manager contracts when ERC20 tokens are transferred to + * payment address. + * It must return its Solidity selector to confirm the token transfer. + * + * @param token_ Token address + * @param amount_ Token amount + * @return Function selector, i.e. `IERC20Receiver.onERC20Received.selector` + */ + function onERC20Received( + IERC20 token_, + uint256 amount_ + ) external override returns (bytes4) { + received = true; + return IERC20Receiver.onERC20Received.selector; + } +} + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title Mock ERC20 receiver with `onERC20Received` that returns a wrong value + */ +contract MockERC20ReceiverRetValErr is + IERC20Receiver +{ + //=============================================================// + // FUNCTIONS // + //=============================================================// + + /** + * Function called by the NFT manager contracts when ERC20 tokens are transferred to + * payment address. + * It must return its Solidity selector to confirm the token transfer. + * + * @param token_ Token address + * @param amount_ Token amount + * @return Function selector, i.e. `IERC20Receiver.onERC20Received.selector` + */ + function onERC20Received( + IERC20 token_, + uint256 amount_ + ) external override returns (bytes4) { + return ""; + } +} + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title Mock ERC20 receiver that does not implement `onERC20Received` + */ +contract MockERC20ReceiverNotImpl +{} diff --git a/contracts/test/MockERC20Token.sol b/contracts/test/MockERC20Token.sol new file mode 100644 index 0000000..c4d197b --- /dev/null +++ b/contracts/test/MockERC20Token.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "./ERC20FixedSupply.sol"; + + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title Mock ERC20 token + */ +contract MockERC20Token is + ERC20FixedSupply +{ + //=============================================================// + // CONSTANTS // + //=============================================================// + + // Token name + string constant private TOKEN_NAME = "Mock ERC20 Token"; + // Token symbol + string constant private TOKEN_SYMBOL = "MT"; + + //=============================================================// + // CONSTRUCTOR // + //=============================================================// + + /** + * Constructor + * @param initialSupply_ Initial supply + */ + constructor( + uint256 initialSupply_ + ) + ERC20FixedSupply(TOKEN_NAME, TOKEN_SYMBOL, initialSupply_) + {} +} diff --git a/contracts/test/MockERC721Token.sol b/contracts/test/MockERC721Token.sol new file mode 100644 index 0000000..04146e3 --- /dev/null +++ b/contracts/test/MockERC721Token.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title Mock ERC721 token + */ +contract MockERC721Token is + ERC721 +{ + //=============================================================// + // CONSTANTS // + //=============================================================// + + // NFT name + string constant private NFT_NAME = "Mock ERC721 Token"; + // NFT symbol + string constant private NFT_SYMBOL = "MT"; + + //=============================================================// + // CONSTRUCTOR // + //=============================================================// + + /** + * Constructor + */ + constructor() + ERC721(NFT_NAME, NFT_SYMBOL) + {} + + //=============================================================// + // FUNCTIONS // + //=============================================================// + + /** + * Mint token `id_` to `to_` + * @param to_ Receiver address + * @param id_ Token ID + */ + function mintTo( + address to_, + uint256 id_ + ) public { + _safeMint(to_, id_); + } +} diff --git a/contracts/test/NftsAuctionUpgraded.sol b/contracts/test/NftsAuctionUpgraded.sol new file mode 100644 index 0000000..710fe0f --- /dev/null +++ b/contracts/test/NftsAuctionUpgraded.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "../NftsAuction.sol"; + + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title To test the contract upgradeability + */ +contract NftsAuctionUpgraded is + NftsAuction +{ + //=============================================================// + // PUBLIC FUNCTIONS // + //=============================================================// + + /** + * New function to check if the contract has been upgraded + */ + function isUpgraded() public pure returns (bool) { + return true; + } +} diff --git a/contracts/test/NftsRedeemerUpgraded.sol b/contracts/test/NftsRedeemerUpgraded.sol new file mode 100644 index 0000000..503b34c --- /dev/null +++ b/contracts/test/NftsRedeemerUpgraded.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "../NftsRedeemer.sol"; + + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title To test the contract upgradeability + */ +contract NftsRedeemerUpgraded is + NftsRedeemer +{ + //=============================================================// + // PUBLIC FUNCTIONS // + //=============================================================// + + /** + * New function to check if the contract has been upgraded + */ + function isUpgraded() public pure returns (bool) { + return true; + } +} diff --git a/contracts/test/NftsSellerUpgraded.sol b/contracts/test/NftsSellerUpgraded.sol new file mode 100644 index 0000000..6479361 --- /dev/null +++ b/contracts/test/NftsSellerUpgraded.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +//=============================================================// +// IMPORTS // +//=============================================================// +import "../NftsSeller.sol"; + + +/** + * @author Emanuele Bellocchia (ebellocchia@gmail.com) + * @title To test the contract upgradeability + */ +contract NftsSellerUpgraded is + NftsSeller +{ + //=============================================================// + // PUBLIC FUNCTIONS // + //=============================================================// + + /** + * New function to check if the contract has been upgraded + */ + function isUpgraded() public pure returns (bool) { + return true; + } +} diff --git a/hardhat.config.ts b/hardhat.config.ts new file mode 100644 index 0000000..dfc4907 --- /dev/null +++ b/hardhat.config.ts @@ -0,0 +1,105 @@ +import { HardhatUserConfig } from "hardhat/config"; +import "@nomicfoundation/hardhat-toolbox"; +// Plugin +import "hardhat-abi-exporter"; +import "hardhat-contract-sizer"; +import "hardhat-docgen"; +// Tasks +import "./tasks/deploy"; +// Dotenv +import "dotenv/config"; + +const config: HardhatUserConfig = { + // Networks + networks: { + // Default + hardhat: {}, + // Localhost + localhost: { + url: "http://127.0.0.1:8545" + }, + // BSC testnet + bscTestnet: { + url: process.env.BSC_TESTNET_RPC, + chainId: 97, + gasPrice: 20000000000, + accounts: {mnemonic: process.env.MNEMONIC} + }, + // BSC mainnet + bsc: { + url: process.env.BSC_MAINNET_RPC, + chainId: 56, + gasPrice: 20000000000, + accounts: {mnemonic: process.env.MNEMONIC} + }, + // Polygon testnet (Mumbai) + polygonMumbai: { + url: process.env.POLYGON_TESTNET_RPC, + chainId: 80001, + gasLimit: 10000000, + gasPrice: 300000000000, + accounts: {mnemonic: process.env.MNEMONIC} + }, + // Polygon mainnet + polygon: { + url: process.env.POLYGON_MAINNET_RPC, + chainId: 137, + gasLimit: 10000000, + gasPrice: 300000000000, + accounts: {mnemonic: process.env.MNEMONIC} + }, + // Ethereum testnet (Sepolia) + ethereumSepolia: { + url: process.env.ETH_TESTNET_RPC, + chainId: 11155111, + accounts: {mnemonic: process.env.MNEMONIC} + }, + // Ethereum mainnet + ethereum: { + url: process.env.ETH_MAINNET_RPC, + chainId: 1, + accounts: {mnemonic: process.env.MNEMONIC} + } + }, + // Compiler + solidity: { + version: "0.8.19", + settings: { + optimizer: { + enabled: true, + runs: 200 + } + } + }, + // ABI export + abiExporter: { + path: "./abi", + clear: true, + flat: false, + only: [":NftsAuction$", ":NftsRedeemer$", ":NftsSeller$"], + runOnCompile: true + }, + // Contract size + contractSizer: { + only: [":NftsAuction$", ":NftsRedeemer$", ":NftsSeller$"], + runOnCompile: true + }, + // Documentation generation + docgen: { + path: "./docs", + clear: true, + runOnCompile: true + }, + // Contract verification + etherscan: { + apiKey: { + bscTestnet: process.env.BSCSCAN_API_KEY, + bsc: process.env.BSCSCAN_API_KEY, + polygonMumbai: process.env.POLYGONSCAN_API_KEY, + polygon: process.env.POLYGONSCAN_API_KEY, + mainnet: process.env.ETHERSCAN_API_KEY + } + } +}; + +export default config; diff --git a/package.json b/package.json new file mode 100644 index 0000000..bda1937 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "nft_managers", + "author": "Emanuele Bellocchia", + "description": "Smart contracts for selling, redeeming (reserving) and auctioning NFTs", + "version": "0.1.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ebellocchia/nft_managers.git" + }, + "scripts": { + "compile": "npx hardhat compile", + "recompile": "npx hardhat compile --force", + "deploy-test-tokens": "npx hardhat deploy-test-tokens --network", + "deploy-all": "npx hardhat deploy-all --network", + "deploy-auction": "npx hardhat deploy-auction --network", + "deploy-redeemer": "npx hardhat deploy-redeemer --network", + "deploy-seller": "npx hardhat deploy-seller --network", + "upgrade-auction-to": "npx hardhat upgrade-auction-to --network", + "upgrade-redeemer-to": "npx hardhat upgrade-redeemer-to --network", + "upgrade-seller-to": "npx hardhat upgrade-seller-to --network", + "verify": "npx hardhat verify --network", + "coverage": "npx hardhat coverage", + "test": "npx hardhat test --parallel", + "run-node": "npx hardhat node", + "contract-docs": "npx hardhat docgen", + "contract-size": "npx hardhat size-contracts", + "contract-flatten-auction": "npx hardhat flatten contracts/NftsAuction.sol > NftsAuction_Flattened.sol", + "contract-flatten-redeemer": "npx hardhat flatten contracts/NftsRedeemer.sol > NftsRedeemer_Flattened.sol", + "contract-flatten-seller": "npx hardhat flatten contracts/NftsSeller.sol > NftsSeller_Flattened.sol" + }, + "devDependencies": { + "@nomicfoundation/hardhat-toolbox": "^2.0.2", + "@nomicfoundation/hardhat-verify": "^1.0.0", + "@nomiclabs/hardhat-etherscan": "^3.1.7", + "@types/node": "^20.2.5", + "hardhat": "^2.17.1", + "hardhat-abi-exporter": "^2.10.1", + "hardhat-contract-sizer": "^2.8.0", + "hardhat-docgen": "^1.3.0" + }, + "dependencies": { + "@openzeppelin/contracts": "^4.9.3", + "@openzeppelin/contracts-upgradeable": "^4.9.3", + "dotenv": "^16.0.3" + } +} diff --git a/reset.bat b/reset.bat new file mode 100644 index 0000000..27faf12 --- /dev/null +++ b/reset.bat @@ -0,0 +1,2 @@ +call clean.bat +npm i --include=dev diff --git a/reset.sh b/reset.sh new file mode 100644 index 0000000..ccdcef7 --- /dev/null +++ b/reset.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +./clean.sh +npm i --include=dev diff --git a/tasks/deploy.ts b/tasks/deploy.ts new file mode 100644 index 0000000..1b61dae --- /dev/null +++ b/tasks/deploy.ts @@ -0,0 +1,193 @@ +import { BigNumber, Contract, ContractFactory } from "ethers"; +import { task } from "hardhat/config"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; + + +async function deployAuction( + hre: HardhatRuntimeEnvironment +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsAuction"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +async function deployRedeemer( + hre: HardhatRuntimeEnvironment +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsRedeemer"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +async function deploySeller( + hre: HardhatRuntimeEnvironment +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsSeller"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +async function deployProxy( + hre: HardhatRuntimeEnvironment, + logic: Contract, + walletAddr: string +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("ERC1967Proxy"); + const instance: Contract = await contract_factory.deploy( + logic.address, + logic.interface.encodeFunctionData("init", [walletAddr]) + ); + await instance.deployed(); + + return instance; +} + +task("deploy-test-tokens", "Deploy mock tokens (ERC20, ERC721, ERC1155)") + .addParam("erc20Supply", "Supply of the mock ERC20 token") + .setAction(async (taskArgs, hre) => { + const erc20_supply: BigNumber = BigNumber.from(taskArgs.erc20Supply); + + console.log("Deploying mock tokens..."); + + const erc20_contract_factory: ContractFactory = await hre.ethers.getContractFactory("MockERC20Token"); + const erc20_instance: Contract = await erc20_contract_factory.deploy(erc20_supply); + await erc20_instance.deployed(); + + const erc721_contract_factory: ContractFactory = await hre.ethers.getContractFactory("MockERC721Token"); + const erc721_instance: Contract = await erc721_contract_factory.deploy(); + await erc721_instance.deployed(); + + const erc1155_contract_factory: ContractFactory = await hre.ethers.getContractFactory("MockERC1155Token"); + const erc1155_instance: Contract = await erc1155_contract_factory.deploy(); + await erc1155_instance.deployed(); + + console.log(`MockERC20Token deployed to ${erc20_instance.address} with supply ${erc20_supply}`); + console.log(`MockERC721Token deployed to ${erc721_instance.address}`); + console.log(`MockERC1155Token deployed to ${erc1155_instance.address}`); + }); + +task("deploy-all", "Deploy contract") + .addParam("walletAddr", "Wallet address") + .setAction(async (taskArgs, hre) => { + console.log("Deploying logic contracts..."); + + const auction_logic: Contract = await deployAuction(hre); + const redeemer_logic: Contract = await deployRedeemer(hre); + const seller_logic: Contract = await deploySeller(hre); + + console.log("Deploying proxy contracts..."); + + const auction_proxy: Contract = await deployProxy(hre, auction_logic, taskArgs.walletAddr); + const redeemer_proxy: Contract = await deployProxy(hre, redeemer_logic, taskArgs.walletAddr); + const seller_proxy: Contract = await deployProxy(hre, seller_logic, taskArgs.walletAddr); + + console.log(`Payment ERC20 wallet: ${taskArgs.walletAddr}`); + console.log(`NftsAuction logic deployed to ${auction_logic.address}`); + console.log(`NftsAuction proxy deployed to ${auction_proxy.address}`); + console.log(`NftsRedeemer logic deployed to ${redeemer_logic.address}`); + console.log(`NftsRedeemer proxy deployed to ${redeemer_proxy.address}`); + console.log(`NftsSeller logic deployed to ${seller_logic.address}`); + console.log(`NftsSeller proxy deployed to ${seller_proxy.address}`); + }); + +task("deploy-auction", "Deploy auction contract") + .addParam("walletAddr", "Wallet address") + .setAction(async (taskArgs, hre) => { + console.log("Deploying logic contract..."); + + const auction_logic: Contract = await deployAuction(hre); + + console.log("Deploying proxy contract..."); + + const auction_proxy: Contract = await deployProxy(hre, auction_logic, taskArgs.walletAddr); + + console.log(`Payment ERC20 wallet: ${taskArgs.walletAddr}`); + console.log(`NftsAuction logic deployed to ${auction_logic.address}`); + console.log(`NftsAuction proxy deployed to ${auction_proxy.address}`); + }); + +task("deploy-redeemer", "Deploy redeemer contract") + .addParam("walletAddr", "Wallet address") + .setAction(async (taskArgs, hre) => { + console.log("Deploying logic contract..."); + + const redeemer_logic: Contract = await deployRedeemer(hre); + + console.log("Deploying proxy contract..."); + + const redeemer_proxy: Contract = await deployProxy(hre, redeemer_logic, taskArgs.walletAddr); + + console.log(`Payment ERC20 wallet: ${taskArgs.walletAddr}`); + console.log(`NftsRedeemer logic deployed to ${redeemer_logic.address}`); + console.log(`NftsRedeemer proxy deployed to ${redeemer_proxy.address}`); + }); + +task("deploy-seller", "Deploy seller contract") + .addParam("walletAddr", "Wallet address") + .setAction(async (taskArgs, hre) => { + console.log("Deploying logic contract..."); + + const seller_logic: Contract = await deploySeller(hre); + + console.log("Deploying proxy contract..."); + + const seller_proxy: Contract = await deployProxy(hre, seller_logic, taskArgs.walletAddr); + + console.log(`Payment ERC20 wallet: ${taskArgs.walletAddr}`); + console.log(`NftsSeller logic deployed to ${seller_logic.address}`); + console.log(`NftsSeller proxy deployed to ${seller_proxy.address}`); + }); + +task("upgrade-auction-to", "Upgrade auction contract") + .addParam("proxyAddr", "Proxy address") + .setAction(async (taskArgs, hre) => { + console.log("Deploying new contract logic..."); + + const auction_logic: Contract = await deployAuction(hre); + + console.log("Upgrading contract..."); + + const proxy_instance: Contract = await (await hre.ethers.getContractFactory("NftsAuction")) + .attach(taskArgs.proxyAddr); + proxy_instance.upgradeTo(auction_logic.address); + + console.log(`NftsAuction updated logic deployed to ${auction_logic.address}`); + }); + +task("upgrade-redeemer-to", "Upgrade redeemer contract") + .addParam("proxyAddr", "Proxy address") + .setAction(async (taskArgs, hre) => { + console.log("Deploying new contract logic..."); + + const redeemer_logic: Contract = await deployRedeemer(hre); + + console.log("Upgrading contract..."); + + const proxy_instance: Contract = await (await hre.ethers.getContractFactory("NftsRedeemer")) + .attach(taskArgs.proxyAddr); + proxy_instance.upgradeTo(redeemer_logic.address); + + console.log(`NftsRedeemer updated logic deployed to ${redeemer_logic.address}`); + }); + +task("upgrade-seller-to", "Upgrade seller contract") + .addParam("proxyAddr", "Proxy address") + .setAction(async (taskArgs, hre) => { + console.log("Deploying new contract logic..."); + + const seller_logic: Contract = await deploySeller(hre); + + console.log("Upgrading contract..."); + + const proxy_instance: Contract = await (await hre.ethers.getContractFactory("NftsSeller")) + .attach(taskArgs.proxyAddr); + proxy_instance.upgradeTo(seller_logic.address); + + console.log(`NftsSeller updated logic deployed to ${seller_logic.address}`); + }); diff --git a/test/auction/NftsAuction.Access.ts b/test/auction/NftsAuction.Access.ts new file mode 100644 index 0000000..8e71f39 --- /dev/null +++ b/test/auction/NftsAuction.Access.ts @@ -0,0 +1,65 @@ +import { expect } from "chai"; +import { Signer } from "ethers"; +// Project +import * as constants from "../common/Constants"; +import { AuctionTestContext, initAuctionTestContext } from "./UtilsAuction"; + + +describe("NftsAuction.Access", () => { + let test_ctx: AuctionTestContext; + + beforeEach(async () => { + test_ctx = await initAuctionTestContext(); + }); + + it("should revert if functions are not called by the owner", async () => { + const not_owner_account: Signer = test_ctx.accounts.signers[0]; + + await expect(test_ctx.auction.connect(not_owner_account).upgradeTo(constants.NULL_ADDRESS)) + .to.be.revertedWith("Ownable: caller is not the owner"); + await expect(test_ctx.auction.connect(not_owner_account).upgradeToAndCall(constants.NULL_ADDRESS, constants.EMPTY_BYTES)) + .to.be.revertedWith("Ownable: caller is not the owner"); + + await expect(test_ctx.auction.connect(not_owner_account).setPaymentERC20Address(constants.NULL_ADDRESS)) + .to.be.revertedWith("Ownable: caller is not the owner"); + await expect(test_ctx.auction.connect(not_owner_account).removeAuction( + test_ctx.mock_erc721.address, + 0 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + + await expect(test_ctx.auction.connect(not_owner_account).createERC721Auction( + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 0, + 1, + 1, + 0 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + await expect(test_ctx.auction.connect(not_owner_account).createERC1155Auction( + test_ctx.mock_erc721.address, + 0, + 1, + test_ctx.mock_erc20.address, + 0, + 1, + 1, + 0 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + + await expect(test_ctx.auction.connect(not_owner_account).withdrawERC721( + test_ctx.mock_erc721.address, + 0 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + await expect(test_ctx.auction.connect(not_owner_account).withdrawERC1155( + test_ctx.mock_erc1155.address, + 0, + 1 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + }); +}); diff --git a/test/auction/NftsAuction.Bid.ts b/test/auction/NftsAuction.Bid.ts new file mode 100644 index 0000000..1624f65 --- /dev/null +++ b/test/auction/NftsAuction.Bid.ts @@ -0,0 +1,316 @@ +import { expect } from "chai"; +import { BigNumber, Contract, Signer } from "ethers"; +import { time } from "@nomicfoundation/hardhat-network-helpers"; +// Project +import * as constants from "../common/Constants"; +import { getMockERC20TokenContractAt } from "../common/UtilsCommon"; +import { + Auction, AuctionStates, + AuctionTestContext, initAuctionTestContextAndCreate +} from "./UtilsAuction"; + + +async function testBid( + auction: Contract, + mock_nft: Contract, + user_1_account: Signer, + user_2_account: Signer +) : Promise { + const nft_id: number = 0; + const auction_data_before: Auction = await auction.Auctions(mock_nft.address, nft_id); + const bid_1_amount: BigNumber = auction_data_before.erc20StartPrice.add(auction_data_before.erc20MinimumBidIncrement); + const bid_2_amount: BigNumber = bid_1_amount.add(auction_data_before.erc20MinimumBidIncrement); + const user_1_address: string = await user_1_account.getAddress(); + const user_2_address: string = await user_2_account.getAddress(); + + // First bid + await expect(await auction.connect(user_1_account).bidAtAuction( + mock_nft.address, + nft_id, + bid_1_amount + )) + .to.emit(auction, "AuctionBid") + .withArgs( + mock_nft.address, + nft_id, + user_1_address, + auction_data_before.erc20Contract, + bid_1_amount + ); + + const auction_data_after_1: Auction = await auction.Auctions(mock_nft.address, nft_id); + expect(auction_data_after_1.nftAmount).to.equal(auction_data_before.nftAmount); + expect(auction_data_after_1.highestBidder).to.equal(user_1_address); + expect(auction_data_after_1.erc20Contract).to.equal(auction_data_before.erc20Contract); + expect(auction_data_after_1.erc20StartPrice).to.equal(auction_data_before.erc20StartPrice); + expect(auction_data_after_1.erc20MinimumBidIncrement).to.equal(auction_data_before.erc20MinimumBidIncrement); + expect(auction_data_after_1.erc20HighestBid).to.equal(bid_1_amount); + expect(auction_data_after_1.startTime).to.equal(auction_data_before.startTime); + expect(auction_data_after_1.endTime).to.equal(auction_data_before.endTime); + expect(auction_data_after_1.state).to.equal(AuctionStates.ACTIVE); + + // Second bid + await expect(await auction.connect(user_2_account).bidAtAuction( + mock_nft.address, + nft_id, + bid_2_amount + )) + .to.emit(auction, "AuctionBid") + .withArgs( + mock_nft.address, + nft_id, + user_2_address, + auction_data_before.erc20Contract, + bid_2_amount + ); + + const auction_data_after_2: Auction = await auction.Auctions(mock_nft.address, nft_id); + expect(auction_data_after_2.nftAmount).to.equal(auction_data_before.nftAmount); + expect(auction_data_after_2.highestBidder).to.equal(user_2_address); + expect(auction_data_after_2.erc20Contract).to.equal(auction_data_before.erc20Contract); + expect(auction_data_after_2.erc20StartPrice).to.equal(auction_data_before.erc20StartPrice); + expect(auction_data_after_2.erc20MinimumBidIncrement).to.equal(auction_data_before.erc20MinimumBidIncrement); + expect(auction_data_after_2.erc20HighestBid).to.equal(bid_2_amount); + expect(auction_data_after_2.startTime).to.equal(auction_data_before.startTime); + expect(auction_data_after_2.endTime).to.equal(auction_data_before.endTime); + expect(auction_data_after_2.state).to.equal(AuctionStates.ACTIVE); + + expect(await auction.isAuctionActive(mock_nft.address, nft_id)) + .to.equal(true); + expect(await auction.isAuctionExpired(mock_nft.address, nft_id)) + .to.equal(false); + expect(await auction.isAuctionCompleted(mock_nft.address, nft_id)) + .to.equal(false); +} + +async function testBidExtension( + auction: Contract, + mock_nft: Contract, + user_account: Signer +) : Promise { + const nft_id: number = 0; + const auction_data_before: Auction = await auction.Auctions(mock_nft.address, nft_id); + const bid_1_amount: BigNumber = auction_data_before.erc20StartPrice.add(auction_data_before.erc20MinimumBidIncrement); + const bid_2_amount: BigNumber = bid_1_amount.add(auction_data_before.erc20MinimumBidIncrement); + const user_address: string = await user_account.getAddress(); + + // Not enough for triggering time extension + await time.increase( + auction_data_before.endTime + .sub(auction_data_before.startTime) + .sub(auction_data_before.extendTimeSec) + .sub(2) + ); + + await auction.connect(user_account).bidAtAuction( + mock_nft.address, + nft_id, + bid_1_amount + ); + + const auction_data_after_1: Auction = await auction.Auctions(mock_nft.address, nft_id); + expect(auction_data_after_1.nftAmount).to.equal(auction_data_before.nftAmount); + expect(auction_data_after_1.highestBidder).to.equal(user_address); + expect(auction_data_after_1.erc20Contract).to.equal(auction_data_before.erc20Contract); + expect(auction_data_after_1.erc20StartPrice).to.equal(auction_data_before.erc20StartPrice); + expect(auction_data_after_1.erc20MinimumBidIncrement).to.equal(auction_data_before.erc20MinimumBidIncrement); + expect(auction_data_after_1.erc20HighestBid).to.equal(bid_1_amount); + expect(auction_data_after_1.startTime).to.equal(auction_data_before.startTime); + expect(auction_data_after_1.endTime).to.equal(auction_data_before.endTime); + expect(auction_data_after_1.state).to.equal(AuctionStates.ACTIVE); + + // Enough for triggering time extension + await time.increase(1); + + await auction.connect(user_account).bidAtAuction( + mock_nft.address, + nft_id, + bid_2_amount + ); + + const auction_data_after_2: Auction = await auction.Auctions(mock_nft.address, nft_id); + expect(auction_data_after_2.nftAmount).to.equal(auction_data_before.nftAmount); + expect(auction_data_after_2.highestBidder).to.equal(user_address); + expect(auction_data_after_2.erc20Contract).to.equal(auction_data_before.erc20Contract); + expect(auction_data_after_2.erc20StartPrice).to.equal(auction_data_before.erc20StartPrice); + expect(auction_data_after_2.erc20MinimumBidIncrement).to.equal(auction_data_before.erc20MinimumBidIncrement); + expect(auction_data_after_2.erc20HighestBid).to.equal(bid_2_amount); + expect(auction_data_after_2.startTime).to.equal(auction_data_before.startTime); + expect(auction_data_after_2.endTime).to.equal(auction_data_before.endTime.add(auction_data_before.extendTimeSec)); + expect(auction_data_after_2.state).to.equal(AuctionStates.ACTIVE); +} + +async function testBidNotCreated( + auction: Contract, + mock_nft: Contract, + user_account: Signer +) : Promise { + const nft_id: number = 1; + + await expect(auction.connect(user_account).bidAtAuction( + mock_nft.address, + nft_id, + 1 + )) + .to.revertedWithCustomError(auction, "AuctionNotActiveError") + .withArgs( + mock_nft.address, + nft_id + ); +} + +async function testBidExpired( + auction: Contract, + mock_nft: Contract, + user_account: Signer +) : Promise { + const nft_id: number = 0; + const auction_data: Auction = await auction.Auctions(mock_nft.address, nft_id); + + await time.increase(auction_data.endTime.sub(auction_data.startTime).add(1)); + + expect(await auction.isAuctionActive(mock_nft.address, nft_id)) + .to.equal(false); + expect(await auction.isAuctionExpired(mock_nft.address, nft_id)) + .to.equal(true); + + await expect(auction.connect(user_account).bidAtAuction( + mock_nft.address, + nft_id, + 1 + )) + .to.revertedWithCustomError(auction, "AuctionNotActiveError") + .withArgs( + mock_nft.address, + nft_id + ); +} + +async function testBidInvalidAmounts( + auction: Contract, + mock_nft: Contract, + user_account: Signer, + user_address: string +) : Promise { + const nft_id: number = 0; + const auction_data: Auction = await auction.Auctions(mock_nft.address, nft_id); + + // Amount less than highest bid + await expect(auction.connect(user_account).bidAtAuction( + mock_nft.address, + nft_id, + auction_data.erc20HighestBid.sub(1) + )) + .to.revertedWithCustomError(auction, "AmountError"); + + // Amount less than minimum bid increment + await expect(auction.connect(user_account).bidAtAuction( + mock_nft.address, + nft_id, + auction_data.erc20HighestBid.add(auction_data.erc20MinimumBidIncrement).sub(1) + )) + .to.revertedWithCustomError(auction, "AmountError"); + + // Amount higher than the user balance + const mock_erc20: Contract = await getMockERC20TokenContractAt(auction_data.erc20Contract); + await expect(auction.connect(user_account).bidAtAuction( + mock_nft.address, + nft_id, + (await mock_erc20.balanceOf(user_address)).add(1) + )) + .to.revertedWithCustomError(auction, "AmountError"); +} + +describe("NftsAuction.Bid", () => { + let test_ctx: AuctionTestContext; + + beforeEach(async () => { + test_ctx = await initAuctionTestContextAndCreate(); + }); + + it("should allow the user to bid for a token auction", async () => { + await testBid( + test_ctx.auction, + test_ctx.mock_erc721, + test_ctx.user_1_account, + test_ctx.user_2_account + ); + await testBid( + test_ctx.auction, + test_ctx.mock_erc1155, + test_ctx.user_1_account, + test_ctx.user_2_account + ); + }); + + it("should extend the auction if a user bid when it is expiring (ERC721)", async () => { + await testBidExtension( + test_ctx.auction, + test_ctx.mock_erc721, + test_ctx.user_1_account + ); + }); + + it("should extend the auction if a user bid when it is expiring (ERC1155)", async () => { + await testBidExtension( + test_ctx.auction, + test_ctx.mock_erc1155, + test_ctx.user_1_account + ); + }); + + it("should revert if bidding for a token auction that is not created", async () => { + await testBidNotCreated( + test_ctx.auction, + test_ctx.mock_erc721, + test_ctx.user_1_account + ); + + await testBidNotCreated( + test_ctx.auction, + test_ctx.mock_erc1155, + test_ctx.user_1_account + ); + }); + + it("should revert if bidding for a token auction that is expired (ERC721)", async () => { + await testBidExpired( + test_ctx.auction, + test_ctx.mock_erc721, + test_ctx.user_1_account + ); + }); + + it("should revert if bidding for a token auction that is expired (ERC1155)", async () => { + await testBidExpired( + test_ctx.auction, + test_ctx.mock_erc1155, + test_ctx.user_1_account + ); + }); + + it("should revert if bidding for a token auction with invalid amounts", async () => { + await testBidInvalidAmounts( + test_ctx.auction, + test_ctx.mock_erc721, + test_ctx.user_1_account, + test_ctx.user_1_address + ); + + await testBidInvalidAmounts( + test_ctx.auction, + test_ctx.mock_erc1155, + test_ctx.user_1_account, + test_ctx.user_1_address + ); + }); + + it("should revert if bidding for a token auction with null addresses", async () => { + await expect(test_ctx.auction.connect(test_ctx.user_1_account).bidAtAuction( + constants.NULL_ADDRESS, + 0, + 1 + )) + .to.revertedWithCustomError(test_ctx.auction, "NullAddressError"); + }); +}); diff --git a/test/auction/NftsAuction.Complete.ts b/test/auction/NftsAuction.Complete.ts new file mode 100644 index 0000000..3785081 --- /dev/null +++ b/test/auction/NftsAuction.Complete.ts @@ -0,0 +1,251 @@ +import { expect } from "chai"; +import { BigNumber, Contract, Signer } from "ethers"; +// Project +import * as constants from "../common/Constants"; +import { + getMockERC20TokenContractAt, getMockERC721TokenContractAt, getMockERC1155TokenContractAt +} from "../common/UtilsCommon"; +import { + Auction, AuctionStates, + AuctionTestContext, initAuctionTestContextAndBid +} from "./UtilsAuction"; + + +async function testComplete( + auction: Contract, + mock_nft: Contract, + owner: Signer, + user_account: Signer +) : Promise { + const is_erc721: boolean = await mock_nft.supportsInterface(constants.ERC721_INTERFACE_ID); + const nft_id: number = 0; + const auction_data_before: Auction = await auction.Auctions(mock_nft.address, nft_id); + const mock_erc20: Contract = await getMockERC20TokenContractAt(auction_data_before.erc20Contract); + + const owner_address: string = await owner.getAddress(); + const user_address: string = await user_account.getAddress(); + const initial_owner_balance: BigNumber = await mock_erc20.balanceOf(owner_address); + const initial_user_balance: BigNumber = await mock_erc20.balanceOf(user_address); + + expect(await auction.isAuctionActive(mock_nft.address, nft_id)) + .to.equal(false); + expect(await auction.isAuctionExpired(mock_nft.address, nft_id)) + .to.equal(true); + expect(await auction.isAuctionCompleted(mock_nft.address, nft_id)) + .to.equal(false); + + await expect(await auction.connect(user_account).completeAuction( + mock_nft.address, + nft_id + )) + .to.emit(auction, "AuctionCompleted") + .withArgs( + mock_nft.address, + nft_id, + auction_data_before.nftAmount, + user_address, + auction_data_before.erc20Contract, + auction_data_before.erc20HighestBid + ); + + // Check data + const auction_data_after: Auction = await auction.Auctions(mock_nft.address, nft_id); + expect(auction_data_after.nftAmount).to.equal(auction_data_before.nftAmount); + expect(auction_data_after.highestBidder).to.equal(user_address); + expect(auction_data_after.erc20Contract).to.equal(auction_data_before.erc20Contract); + expect(auction_data_after.erc20StartPrice).to.equal(auction_data_before.erc20StartPrice); + expect(auction_data_after.erc20MinimumBidIncrement).to.equal(auction_data_before.erc20MinimumBidIncrement); + expect(auction_data_after.erc20HighestBid).to.equal(auction_data_before.erc20HighestBid); + expect(auction_data_after.startTime).to.equal(auction_data_before.startTime); + expect(auction_data_after.endTime).to.equal(auction_data_before.endTime); + expect(auction_data_after.state).to.equal(AuctionStates.COMPLETED); + + // Check state + expect(await auction.isAuctionActive(mock_nft.address, nft_id)) + .to.equal(false); + expect(await auction.isAuctionExpired(mock_nft.address, nft_id)) + .to.equal(false); + expect(await auction.isAuctionCompleted(mock_nft.address, nft_id)) + .to.equal(true); + + // Check token transfers + expect(await mock_erc20.balanceOf(owner_address)) + .to.equal(initial_owner_balance.add(auction_data_after.erc20HighestBid)); + expect(await mock_erc20.balanceOf(user_address)) + .to.equal(initial_user_balance.sub(auction_data_after.erc20HighestBid)); + + if (is_erc721) { + const mock_erc721: Contract = await getMockERC721TokenContractAt(mock_nft.address); + expect(await mock_erc721.ownerOf(nft_id)) + .to.equal(user_address); + } + else { + const mock_erc1155: Contract = await getMockERC1155TokenContractAt(mock_nft.address); + expect(await mock_erc1155.balanceOf(user_address, nft_id)) + .to.equal(auction_data_after.nftAmount); + } +} + +async function testNotEnoughBalance( + auction: Contract, + mock_nft: Contract, + mock_erc20: Contract, + user_account: Signer, + other_address: string +) : Promise { + const user_address: string = await user_account.getAddress(); + const balance: BigNumber = await mock_erc20.balanceOf(user_address); + await mock_erc20 + .connect(user_account) + .transfer(other_address, balance.sub(1)); + + await expect(auction.connect(user_account).completeAuction( + mock_nft.address, + 0 + )) + .to.be.revertedWith("ERC20: transfer amount exceeds balance"); +} + +async function testNotExpired( + auction: Contract, + mock_nft: Contract, + mock_erc20: Contract, + user_account: Signer +) : Promise { + const is_erc721: boolean = await mock_nft.supportsInterface(constants.ERC721_INTERFACE_ID); + const nft_id: number = 1; + + // Inactive auction (not created) + await expect(auction.connect(user_account).completeAuction( + mock_nft.address, + nft_id + )) + .to.revertedWithCustomError(auction, "AuctionNotExpiredError") + .withArgs( + mock_nft.address, + nft_id + ); + + // Active auction + if (is_erc721) { + await auction.createERC721Auction( + mock_nft.address, + nft_id, + mock_erc20.address, + 1, + 1, + 10, + 0 + ); + } + else { + await auction.createERC1155Auction( + mock_nft.address, + nft_id, + 1, + mock_erc20.address, + 1, + 1, + 10, + 0 + ); + } + + await expect(auction.connect(user_account).completeAuction( + mock_nft.address, + nft_id + )) + .to.revertedWithCustomError(auction, "AuctionNotExpiredError") + .withArgs( + mock_nft.address, + nft_id + ); +} + +async function testNotWinner( + auction: Contract, + mock_nft: Contract, + user_account: Signer +) : Promise { + const user_address: string = await user_account.getAddress(); + + await expect(auction.connect(user_account).completeAuction( + mock_nft.address, + 0 + )) + .to.revertedWithCustomError(auction, "BidderNotWinnerError") + .withArgs(user_address); +} + +describe("NftsAuction.Complete", () => { + let test_ctx: AuctionTestContext; + + beforeEach(async () => { + test_ctx = await initAuctionTestContextAndBid(); + }); + + it("should complete a token auction", async () => { + await testComplete( + test_ctx.auction, + test_ctx.mock_erc721, + test_ctx.accounts.owner, + test_ctx.user_1_account + ); + await testComplete( + test_ctx.auction, + test_ctx.mock_erc1155, + test_ctx.accounts.owner, + test_ctx.user_2_account + ); + }); + + it("should revert if completing a token auction with not enough balance", async () => { + await testNotEnoughBalance( + test_ctx.auction, + test_ctx.mock_erc721, + test_ctx.mock_erc20, + test_ctx.user_1_account, + test_ctx.user_2_address + ); + await testNotEnoughBalance( + test_ctx.auction, + test_ctx.mock_erc1155, + test_ctx.mock_erc20, + test_ctx.user_2_account, + test_ctx.user_1_address + ); + }); + + it("should revert if completing a token auction that is not expired", async () => { + await testNotExpired( + test_ctx.auction, + test_ctx.mock_erc721, + test_ctx.mock_erc20, + test_ctx.user_1_account + ); + await testNotExpired( + test_ctx.auction, + test_ctx.mock_erc1155, + test_ctx.mock_erc20, + test_ctx.user_1_account + ); + }); + + it("should revert if completing a token auction with a user that is not the winner", async () => { + await testNotWinner( + test_ctx.auction, + test_ctx.mock_erc721, + test_ctx.user_2_account + ); + await testNotWinner( + test_ctx.auction, + test_ctx.mock_erc1155, + test_ctx.user_1_account + ); + }); + + it("should revert if completing a token auction with null addresses", async () => { + await expect(test_ctx.auction.completeAuction(constants.NULL_ADDRESS, 0)) + .to.be.revertedWithCustomError(test_ctx.auction, "NullAddressError"); + }); +}); diff --git a/test/auction/NftsAuction.Create.ts b/test/auction/NftsAuction.Create.ts new file mode 100644 index 0000000..7b274dd --- /dev/null +++ b/test/auction/NftsAuction.Create.ts @@ -0,0 +1,332 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +import { time } from "@nomicfoundation/hardhat-network-helpers"; +// Project +import * as constants from "../common/Constants"; +import { + Auction, AuctionStates, + AuctionTestContext, initAuctionTestContextAndToken +} from "./UtilsAuction"; + + +async function testCreate( + auction: Contract, + mock_nft: Contract, + mock_erc20: Contract +) : Promise { + const is_erc721: boolean = await mock_nft.supportsInterface(constants.ERC721_INTERFACE_ID); + const duration_sec: number = 60 * 60; + const extend_time_sec: number = 60; + const erc20_start_price: number = 100; + const erc20_min_bid_increment: number = 10; + const nft_amount: number = is_erc721 ? 0 : 1; + const nft_id: number = 0; + + if (is_erc721) { + await expect(await auction.createERC721Auction( + mock_nft.address, + nft_id, + mock_erc20.address, + erc20_start_price, + erc20_min_bid_increment, + duration_sec, + extend_time_sec + )) + .to.emit(auction, "AuctionCreated") + .withArgs( + mock_nft.address, + nft_id, + nft_amount, + mock_erc20.address, + erc20_start_price, + erc20_min_bid_increment, + await time.latest(), + duration_sec, + extend_time_sec + ); + } + else { + await expect(await auction.createERC1155Auction( + mock_nft.address, + nft_id, + nft_amount, + mock_erc20.address, + erc20_start_price, + erc20_min_bid_increment, + duration_sec, + extend_time_sec + )) + .to.emit(auction, "AuctionCreated") + .withArgs( + mock_nft.address, + nft_id, + nft_amount, + mock_erc20.address, + erc20_start_price, + erc20_min_bid_increment, + await time.latest(), + duration_sec, + extend_time_sec + ); + } + + const curr_time: number = await time.latest(); + const auction_data: Auction = await auction.Auctions(mock_nft.address, nft_id); + expect(auction_data.nftAmount).to.equal(nft_amount); + expect(auction_data.highestBidder).to.equal(constants.NULL_ADDRESS); + expect(auction_data.erc20Contract).to.equal(mock_erc20.address); + expect(auction_data.erc20StartPrice).to.equal(erc20_start_price); + expect(auction_data.erc20MinimumBidIncrement).to.equal(erc20_min_bid_increment); + expect(auction_data.erc20HighestBid).to.equal(erc20_start_price); + expect(auction_data.startTime).to.equal(curr_time); + expect(auction_data.endTime).to.equal(curr_time + duration_sec); + expect(auction_data.state).to.equal(AuctionStates.ACTIVE); + + expect(await auction.isAuctionActive(mock_nft.address, nft_id)) + .to.equal(true); + expect(await auction.isAuctionExpired(mock_nft.address, nft_id)) + .to.equal(false); +} + +describe("NftsAuction.Create", () => { + let test_ctx: AuctionTestContext; + + beforeEach(async () => { + test_ctx = await initAuctionTestContextAndToken(); + }); + + it("should create a token auction", async () => { + await testCreate( + test_ctx.auction, + test_ctx.mock_erc721, + test_ctx.mock_erc20 + ); + await testCreate( + test_ctx.auction, + test_ctx.mock_erc1155, + test_ctx.mock_erc20 + ); + }); + + it("should revert if creating a ERC721 token auction with null addresses", async () => { + await expect(test_ctx.auction.createERC721Auction( + constants.NULL_ADDRESS, + 0, + test_ctx.mock_erc20.address, + 1, + 1, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "NullAddressError"); + + await expect(test_ctx.auction.createERC721Auction( + test_ctx.mock_erc721.address, + 0, + constants.NULL_ADDRESS, + 1, + 1, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "NullAddressError"); + }); + + it("should revert if creating a ERC1155 token auction with null addresses", async () => { + await expect(test_ctx.auction.createERC1155Auction( + constants.NULL_ADDRESS, + 0, + 1, + test_ctx.mock_erc20.address, + 1, + 1, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "NullAddressError"); + + await expect(test_ctx.auction.createERC1155Auction( + test_ctx.mock_erc1155.address, + 0, + 1, + constants.NULL_ADDRESS, + 1, + 1, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "NullAddressError"); + }); + + it("should revert if creating a ERC721 token auction that is already existent", async () => { + await test_ctx.auction.createERC721Auction( + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 1, + 1, + 1, + 0 + ); + await expect(test_ctx.auction.createERC721Auction( + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 1, + 1, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "AuctionAlreadyActiveError") + .withArgs(test_ctx.mock_erc721.address, 0); + }); + + it("should revert if creating a ERC1155 token auction that is already existent", async () => { + await test_ctx.auction.createERC1155Auction( + test_ctx.mock_erc1155.address, + 0, + 1, + test_ctx.mock_erc20.address, + 1, + 1, + 1, + 0 + ); + await expect(test_ctx.auction.createERC1155Auction( + test_ctx.mock_erc1155.address, + 0, + 1, + test_ctx.mock_erc20.address, + 1, + 1, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "AuctionAlreadyActiveError") + .withArgs(test_ctx.mock_erc1155.address, 0); + }); + + it("should revert if creating a ERC721 token auction with an invalid ID", async () => { + const nft_id: number = constants.ERC721_TOKEN_SUPPLY; + + await expect(test_ctx.auction.createERC721Auction( + test_ctx.mock_erc721.address, + nft_id, + test_ctx.mock_erc20.address, + 1, + 1, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "NftError") + .withArgs(test_ctx.mock_erc721.address, nft_id); + }); + + it("should revert if creating a ERC721 token auction with a token owned by another address", async () => { + const nft_id: number = constants.ERC721_TOKEN_SUPPLY - 1; + + await expect(test_ctx.auction.createERC721Auction( + test_ctx.mock_erc721.address, + nft_id, + test_ctx.mock_erc20.address, + 1, + 1, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "NftError") + .withArgs(test_ctx.mock_erc721.address, nft_id); + }); + + it("should revert if creating a ERC1155 token auction with an invalid ID", async () => { + const nft_id: number = constants.ERC1155_TOKEN_SUPPLY; + + await expect(test_ctx.auction.createERC1155Auction( + test_ctx.mock_erc1155.address, + nft_id, + 1, + test_ctx.mock_erc20.address, + 1, + 1, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "NftError") + .withArgs(test_ctx.mock_erc1155.address, nft_id); + }); + + it("should revert if creating a ERC721 token auction with an invalid amount", async () => { + await expect(test_ctx.auction.createERC721Auction( + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 1, + 0, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "AmountError"); + + await expect(test_ctx.auction.createERC721Auction( + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 1, + 1, + 0, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "AmountError"); + }); + + it("should revert if creating a ERC1155 token auction with an invalid amount", async () => { + await expect(test_ctx.auction.createERC1155Auction( + test_ctx.mock_erc1155.address, + 0, + 0, + test_ctx.mock_erc20.address, + 1, + 1, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "AmountError"); + + await expect(test_ctx.auction.createERC1155Auction( + test_ctx.mock_erc1155.address, + 0, + constants.ERC1155_TOKEN_AMOUNT + 1, + test_ctx.mock_erc20.address, + 1, + 1, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "NftError") + .withArgs(test_ctx.mock_erc1155.address, 0); + + await expect(test_ctx.auction.createERC1155Auction( + test_ctx.mock_erc1155.address, + 0, + 1, + test_ctx.mock_erc20.address, + 1, + 0, + 1, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "AmountError"); + + await expect(test_ctx.auction.createERC1155Auction( + test_ctx.mock_erc1155.address, + 0, + 1, + test_ctx.mock_erc20.address, + 1, + 1, + 0, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "AmountError"); + }); +}); diff --git a/test/auction/NftsAuction.Deploy.ts b/test/auction/NftsAuction.Deploy.ts new file mode 100644 index 0000000..ead8ff5 --- /dev/null +++ b/test/auction/NftsAuction.Deploy.ts @@ -0,0 +1,46 @@ +import { expect } from "chai"; +// Project +import * as constants from "../common/Constants"; +import { + AuctionTestContext, + deployAuctionContract, deployAuctionUpgradedContract, + getAuctionUpgradedContractAt, initAuctionTestContext +} from "./UtilsAuction"; + + +describe("NftsAuction.Deploy", () => { + let test_ctx: AuctionTestContext; + + beforeEach(async () => { + test_ctx = await initAuctionTestContext(); + }); + + it("should be constructed correctly", async () => { + const owner_address: string = await test_ctx.accounts.owner.getAddress(); + + expect(await test_ctx.auction.owner()).to.equal(owner_address); + expect(await test_ctx.auction.paymentERC20Address()).to.equal(test_ctx.payment_erc20_address); + }); + + it("should upgrade the logic", async () => { + const new_logic = await deployAuctionUpgradedContract(); + + await expect(await test_ctx.auction.upgradeTo(new_logic.address)) + .not.to.be.reverted; + + test_ctx.auction = await getAuctionUpgradedContractAt(test_ctx.auction.address); // Update ABI + expect(await test_ctx.auction.isUpgraded()) + .to.equal(true); + }); + + it("should revert if initializing more than once", async () => { + await expect(test_ctx.auction.init(constants.NULL_ADDRESS)) + .to.be.revertedWith("Initializable: contract is already initialized"); + }); + + it("should revert if initializing the logic contract without a proxy", async () => { + const nft = await deployAuctionContract(); + await expect(nft.init(constants.NULL_ADDRESS)) + .to.be.revertedWith("Initializable: contract is already initialized"); + }); +}); diff --git a/test/auction/NftsAuction.ERC20Receiver.ts b/test/auction/NftsAuction.ERC20Receiver.ts new file mode 100644 index 0000000..1c7e708 --- /dev/null +++ b/test/auction/NftsAuction.ERC20Receiver.ts @@ -0,0 +1,51 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +// Project +import { + deployMockERC20ReceiverContract, deployMockERC20ReceiverRetValErrContract, deployMockERC20ReceiverNotImplContract +} from "../common/UtilsCommon"; +import { AuctionTestContext, initAuctionTestContextAndBid } from "./UtilsAuction"; + + +describe("NftsAuction.ERC20Receiver", () => { + let test_ctx: AuctionTestContext; + + beforeEach(async () => { + test_ctx = await initAuctionTestContextAndBid(); + }); + + it("should call the onERC20Received function if the payment ERC20 address is a contract", async () => { + const erc20_receiver: Contract = await deployMockERC20ReceiverContract(); + expect(await erc20_receiver.received()).to.equal(false); + + await test_ctx.auction.setPaymentERC20Address(erc20_receiver.address); + await test_ctx.auction.connect(test_ctx.accounts.signers[0]).completeAuction( + test_ctx.mock_erc721.address, + 0 + ); + + expect(await erc20_receiver.received()).to.equal(true); + }); + + it("should revert if the onERC20Received function returns the wrong value", async () => { + const erc20_receiver: Contract = await deployMockERC20ReceiverRetValErrContract(); + + await test_ctx.auction.setPaymentERC20Address(erc20_receiver.address); + await expect(test_ctx.auction.connect(test_ctx.accounts.signers[0]).completeAuction( + test_ctx.mock_erc721.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "IERC20ReceiverRetValError"); + }); + + it("should revert if the onERC20Received function is not implemented", async () => { + const erc20_receiver: Contract = await deployMockERC20ReceiverNotImplContract(); + + await test_ctx.auction.setPaymentERC20Address(erc20_receiver.address); + await expect(test_ctx.auction.connect(test_ctx.accounts.signers[0]).completeAuction( + test_ctx.mock_erc721.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "IERC20ReceiverNotImplError"); + }); +}); diff --git a/test/auction/NftsAuction.PaymentERC20Address.ts b/test/auction/NftsAuction.PaymentERC20Address.ts new file mode 100644 index 0000000..08448f5 --- /dev/null +++ b/test/auction/NftsAuction.PaymentERC20Address.ts @@ -0,0 +1,25 @@ +// Project +import { testPaymentERC20AddressSet, testPaymentERC20AddressNullAddress } from "../common/TestPaymentERC20Address"; +import { AuctionTestContext, initAuctionTestContext } from "./UtilsAuction"; + + +describe("NftsAuction.PaymentERC20Address", () => { + let test_ctx: AuctionTestContext; + + beforeEach(async () => { + test_ctx = await initAuctionTestContext(); + }); + + it("should set the payment wallet", async () => { + await testPaymentERC20AddressSet( + test_ctx.auction, + test_ctx.user_1_address + ); + }); + + it("should revert if setting a payment wallet with null address", async () => { + await testPaymentERC20AddressNullAddress( + test_ctx.auction + ); + }); +}); diff --git a/test/auction/NftsAuction.Remove.ts b/test/auction/NftsAuction.Remove.ts new file mode 100644 index 0000000..e6d6574 --- /dev/null +++ b/test/auction/NftsAuction.Remove.ts @@ -0,0 +1,114 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +import { time } from "@nomicfoundation/hardhat-network-helpers"; +// Project +import * as constants from "../common/Constants"; +import { + Auction, AuctionStates, + AuctionTestContext, initAuctionTestContextAndCreate +} from "./UtilsAuction"; + + +async function testRemove( + auction: Contract, + mock_nft: Contract +) : Promise { + const nft_id: number = 0; + const auction_data_before: Auction = await auction.Auctions(mock_nft.address, nft_id); + + await expect(await auction.removeAuction( + mock_nft.address, + nft_id + )) + .to.emit(auction, "AuctionRemoved") + .withArgs( + mock_nft.address, + nft_id + ); + + const auction_data_after: Auction = await auction.Auctions(mock_nft.address, nft_id); + expect(auction_data_after.nftAmount).to.equal(auction_data_before.nftAmount); + expect(auction_data_after.highestBidder).to.equal(auction_data_before.highestBidder); + expect(auction_data_after.erc20Contract).to.equal(auction_data_before.erc20Contract); + expect(auction_data_after.erc20StartPrice).to.equal(auction_data_before.erc20StartPrice); + expect(auction_data_after.erc20MinimumBidIncrement).to.equal(auction_data_before.erc20MinimumBidIncrement); + expect(auction_data_after.erc20HighestBid).to.equal(auction_data_before.erc20HighestBid); + expect(auction_data_after.startTime).to.equal(auction_data_before.startTime); + expect(auction_data_after.endTime).to.equal(auction_data_before.endTime); + expect(auction_data_after.state).to.equal(AuctionStates.INACTIVE); +} + +async function testRemoveExpired( + auction: Contract, + mock_nft: Contract +) : Promise { + const nft_id: number = 0; + const auction_data: Auction = await auction.Auctions(mock_nft.address, nft_id); + + await time.increase(auction_data.endTime.sub(auction_data.startTime)); + + await testRemove(auction, mock_nft); +} + +async function testRemoveNotCreated( + auction: Contract, + mock_nft: Contract +) : Promise { + const nft_id: number = 1; + + await expect(auction.removeAuction( + mock_nft.address, + nft_id + )) + .to.be.revertedWithCustomError(auction, "AuctionNotActiveError") + .withArgs( + mock_nft.address, + nft_id + ); +} + +describe("NftsAuction.Remove", () => { + let test_ctx: AuctionTestContext; + + beforeEach(async () => { + test_ctx = await initAuctionTestContextAndCreate(); + }); + + it("should remove a token auction", async () => { + await testRemove( + test_ctx.auction, + test_ctx.mock_erc721 + ); + await testRemove( + test_ctx.auction, + test_ctx.mock_erc1155 + ); + }); + + it("should remove an expired token auction", async () => { + await testRemoveExpired( + test_ctx.auction, + test_ctx.mock_erc721 + ); + await testRemoveExpired( + test_ctx.auction, + test_ctx.mock_erc1155 + ); + }); + + it("should revert if removing a token auction that is not created", async () => { + await testRemoveNotCreated( + test_ctx.auction, + test_ctx.mock_erc721 + ); + await testRemoveNotCreated( + test_ctx.auction, + test_ctx.mock_erc1155 + ); + }); + + it("should revert if removing a token auction with null addresses", async () => { + await expect(test_ctx.auction.removeAuction(constants.NULL_ADDRESS, 0)) + .to.be.revertedWithCustomError(test_ctx.auction, "NullAddressError"); + }); +}); diff --git a/test/auction/NftsAuction.Withdraw.ts b/test/auction/NftsAuction.Withdraw.ts new file mode 100644 index 0000000..730c45d --- /dev/null +++ b/test/auction/NftsAuction.Withdraw.ts @@ -0,0 +1,135 @@ +import { expect } from "chai"; +// Project +import * as constants from "../common/Constants"; +import { + testERC721Withdraw, testERC1155Withdraw, + testERC721WithdrawNullAddress, testERC721WithdrawInvalidId, + testERC1155WithdrawNullAddress, testERC1155WithdrawInvalidId, testERC1155WithdrawInvalidAmount +} from "../common/TestWithdraw"; +import { AuctionTestContext, initAuctionTestContextAndToken } from "./UtilsAuction"; + + +describe("NftsAuction.Withdraw", () => { + let test_ctx: AuctionTestContext; + + beforeEach(async () => { + test_ctx = await initAuctionTestContextAndToken(); + }); + + it("should withdraw a ERC721 token", async () => { + await testERC721Withdraw( + test_ctx.auction, + test_ctx + ); + }); + + it("should withdraw a ERC1155 token", async () => { + await testERC1155Withdraw( + test_ctx.auction, + test_ctx + ); + }); + + it("should revert if withdrawing a ERC721 token with null address", async () => { + await testERC721WithdrawNullAddress( + test_ctx.auction + ); + }); + + it("should revert if withdrawing a ERC721 token with an invalid token ID", async () => { + await testERC721WithdrawInvalidId( + test_ctx.auction, + test_ctx + ); + }); + + it("should revert if withdrawing a ERC1155 token with null address", async () => { + await testERC1155WithdrawNullAddress( + test_ctx.auction + ); + }); + + it("should revert if withdrawing a ERC1155 token with an invalid token ID", async () => { + await testERC1155WithdrawInvalidId( + test_ctx.auction, + test_ctx + ); + }); + + it("should revert if withdrawing a ERC1155 token with an invalid amount", async () => { + await testERC1155WithdrawInvalidAmount( + test_ctx.auction, + test_ctx + ); + }); + + it("should revert if withdrawing a ERC721 token whose action is created", async () => { + const duration_sec: number = 24 * 60 * 60; + const erc20_start_price: number = constants.ERC20_TOKEN_SUPPLY / 10; + const erc20_min_bid_increment: number = erc20_start_price / 100; + const nft_id: number = 0; + + await test_ctx.auction.createERC721Auction( + test_ctx.mock_erc721.address, + nft_id, + test_ctx.mock_erc20.address, + erc20_start_price, + erc20_min_bid_increment, + duration_sec, + 0 + ); + + await expect(test_ctx.auction.withdrawERC721( + test_ctx.mock_erc721.address, + nft_id + )) + .to.be.revertedWithCustomError(test_ctx.auction, "WithdrawError") + .withArgs( + test_ctx.mock_erc721.address, + nft_id + ); + }); + + it("should revert if withdrawing a ERC1155 token with a higher amount than the created one", async () => { + const withdrawable_amount: number = 2; + const duration_sec: number = 24 * 60 * 60; + const erc20_start_price: number = constants.ERC20_TOKEN_SUPPLY / 10; + const erc20_min_bid_increment: number = erc20_start_price / 100; + const nft_amount: number = constants.ERC1155_TOKEN_AMOUNT - withdrawable_amount; + const nft_id: number = 0; + const owner_address: string = await test_ctx.accounts.owner.getAddress(); + + await test_ctx.auction.createERC1155Auction( + test_ctx.mock_erc1155.address, + nft_id, + nft_amount, + test_ctx.mock_erc20.address, + erc20_start_price, + erc20_min_bid_increment, + duration_sec, + 0 + ); + + // More than withdrawable amount + await expect(test_ctx.auction.withdrawERC1155( + test_ctx.mock_erc1155.address, + nft_id, + withdrawable_amount + 1 + )) + .to.be.revertedWithCustomError(test_ctx.auction, "WithdrawError") + .withArgs( + test_ctx.mock_erc1155.address, + nft_id + ); + + // Equal to withdrawable amount + await expect(test_ctx.auction.withdrawERC1155( + test_ctx.mock_erc1155.address, + nft_id, + withdrawable_amount + )) + .not.to.be.reverted; + expect(await test_ctx.mock_erc1155.balanceOf(owner_address, nft_id)) + .to.equal(withdrawable_amount); + }); +}); diff --git a/test/auction/UtilsAuction.ts b/test/auction/UtilsAuction.ts new file mode 100644 index 0000000..599e78a --- /dev/null +++ b/test/auction/UtilsAuction.ts @@ -0,0 +1,186 @@ +import { BigNumber, Contract, ContractFactory } from "ethers"; +import hre from "hardhat"; +import { time } from "@nomicfoundation/hardhat-network-helpers"; +// Project +import * as constants from "../common/Constants"; +import { + Accounts, TestContext, + initTestContext, initTokens, deployProxyContract +} from "../common/UtilsCommon"; + + +// +// Custom types +// + +type AuctionStatesType = { + [key: string]: number; +}; + +// +// Constants +// + +export const AuctionStates: AuctionStatesType = { + INACTIVE: 0, + ACTIVE: 1, + COMPLETED: 2 +}; + +// +// Interfaces +// + +export interface Auction { + nftAmount: BigNumber; + highestBidder: string; + erc20Contract: string; + erc20StartPrice: BigNumber; + erc20MinimumBidIncrement: BigNumber; + erc20HighestBid: BigNumber; + startTime: BigNumber; + endTime: BigNumber; + extendTimeSec: BigNumber; + state: AuctionStatesType; +} + +export interface AuctionTestContext extends TestContext { + auction: Contract; +} + +// +// Exported functions +// + +export async function initAuctionTestContext() : Promise { + const test_ctx: TestContext = await initTestContext(); + const auction: Contract = await deployAuctionProxyContract(test_ctx.payment_erc20_address); + + return { + accounts: test_ctx.accounts, + mock_erc20: test_ctx.mock_erc20, + mock_erc721: test_ctx.mock_erc721, + mock_erc1155: test_ctx.mock_erc1155, + payment_erc20_address: test_ctx.payment_erc20_address, + auction: auction, + user_1_account: test_ctx.user_1_account, + user_1_address: test_ctx.user_1_address, + user_2_account: test_ctx.user_2_account, + user_2_address: test_ctx.user_2_address, + user_3_account: test_ctx.user_3_account, + user_3_address: test_ctx.user_3_address + }; +} + +export async function initAuctionTestContextAndToken() : Promise { + const test_ctx: AuctionTestContext = await initAuctionTestContext(); + await initTokens( + test_ctx.accounts, + test_ctx.auction.address, + test_ctx.mock_erc20, + test_ctx.mock_erc721, + test_ctx.mock_erc1155, + test_ctx.user_1_account, + test_ctx.user_2_account, + test_ctx.user_3_account + ); + + return test_ctx; +} + +export async function initAuctionTestContextAndCreate() : Promise { + const test_ctx: AuctionTestContext = await initAuctionTestContextAndToken(); + const duration_sec: number = 24 * 60 * 60; + const extend_time_sec: number = 2 * 60; + const erc20_start_price: number = constants.ERC20_TOKEN_SUPPLY / 20; + const erc20_min_bid_increment: number = erc20_start_price / 10; + const nft_id: number = 0; + + await test_ctx.auction.createERC721Auction( + test_ctx.mock_erc721.address, + nft_id, + test_ctx.mock_erc20.address, + erc20_start_price, + erc20_min_bid_increment, + duration_sec, + extend_time_sec + ); + await test_ctx.auction.createERC1155Auction( + test_ctx.mock_erc1155.address, + nft_id, + constants.ERC1155_TOKEN_AMOUNT, + test_ctx.mock_erc20.address, + erc20_start_price, + erc20_min_bid_increment, + duration_sec, + extend_time_sec + ); + + return test_ctx; +} + +export async function initAuctionTestContextAndBid() : Promise { + const test_ctx: AuctionTestContext = await initAuctionTestContextAndCreate(); + const nft_id: number = 0; + const auction_data_erc721: Auction = await test_ctx.auction.Auctions(test_ctx.mock_erc721.address, nft_id); + const auction_data_erc1155: Auction = await test_ctx.auction.Auctions(test_ctx.mock_erc1155.address, nft_id); + + await test_ctx.auction.connect(test_ctx.user_1_account).bidAtAuction( + test_ctx.mock_erc721.address, + nft_id, + auction_data_erc721.erc20StartPrice.add(auction_data_erc721.erc20MinimumBidIncrement) + ); + await test_ctx.auction.connect(test_ctx.user_2_account).bidAtAuction( + test_ctx.mock_erc1155.address, + nft_id, + auction_data_erc1155.erc20StartPrice.add(auction_data_erc1155.erc20MinimumBidIncrement) + ); + + // Make sure the auctions are expired + await time.increase(auction_data_erc721.endTime.sub(auction_data_erc721.startTime).add(1)); + + return test_ctx; +} + +export async function deployAuctionContract() : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsAuction"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +export async function deployAuctionUpgradedContract() : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsAuctionUpgraded"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +export async function getAuctionUpgradedContractAt( + address: string +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsAuctionUpgraded"); + return contract_factory.attach(address); +} + +// +// Not exported functions +// + +async function getAuctionContractAt( + address: string +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsAuction"); + return contract_factory.attach(address); +} + +async function deployAuctionProxyContract( + paymentERC20Address: string +) : Promise { + const auction_logic_instance: Contract = await deployAuctionContract(); + const proxy_instance: Contract = await deployProxyContract(auction_logic_instance, paymentERC20Address); + + return getAuctionContractAt(proxy_instance.address); +} diff --git a/test/common/Constants.ts b/test/common/Constants.ts new file mode 100644 index 0000000..77bf00b --- /dev/null +++ b/test/common/Constants.ts @@ -0,0 +1,15 @@ +import { BigNumber } from "ethers"; + +// +// Constants for testing +// +export const NULL_ADDRESS: string = "0x0000000000000000000000000000000000000000"; +export const EMPTY_BYTES: string = "0x"; +export const UINT256_MAX: BigNumber = BigNumber.from("115792089237316195423570985008687907853269984665640564039457584007913129639935"); +export const ERC721_INTERFACE_ID: string = "0x80ac58cd"; +export const ERC1155_INTERFACE_ID: string = "0xd9b67a26"; +export const ERC20_TOKEN_SUPPLY: number = 10000; +export const ERC721_TOKEN_SUPPLY: number = 5; +export const ERC1155_TOKEN_SUPPLY: number = 5; +export const ERC1155_TOKEN_AMOUNT: number = 5; +export const TOTAL_TEST_INVESTORS: number = 5; diff --git a/test/common/TestPaymentERC20Address.ts b/test/common/TestPaymentERC20Address.ts new file mode 100644 index 0000000..bec23e8 --- /dev/null +++ b/test/common/TestPaymentERC20Address.ts @@ -0,0 +1,28 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +// Project +import * as constants from "./Constants"; + + +// +// Exported functions +// + +export async function testPaymentERC20AddressSet( + manager: Contract, + paymentERC20Address: string +) : Promise { + const old_address: string = await manager.paymentERC20Address(); + await expect(await manager.setPaymentERC20Address(paymentERC20Address)) + .to.emit(manager, "PaymentERC20AddressChanged") + .withArgs(old_address, paymentERC20Address); + expect(await manager.paymentERC20Address()) + .to.equal(paymentERC20Address); +} + +export async function testPaymentERC20AddressNullAddress( + manager: Contract +) : Promise { + await expect(manager.setPaymentERC20Address(constants.NULL_ADDRESS)) + .to.be.revertedWithCustomError(manager, "NullAddressError"); +} diff --git a/test/common/TestWithdraw.ts b/test/common/TestWithdraw.ts new file mode 100644 index 0000000..f0731fc --- /dev/null +++ b/test/common/TestWithdraw.ts @@ -0,0 +1,109 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +// Project +import * as constants from "./Constants"; +import { TestContext } from "./UtilsCommon"; + + +// +// Exported functions +// + +export async function testERC721Withdraw( + manager: Contract, + test_ctx: TestContext +) : Promise { + const nft_id: number = 0; + const owner_address: string = await test_ctx.accounts.owner.getAddress(); + + await expect(await manager.withdrawERC721( + test_ctx.mock_erc721.address, + nft_id + )) + .to.emit(manager, "ERC721Withdrawn") + .withArgs( + owner_address, + test_ctx.mock_erc721.address, + nft_id + ); + + expect(await test_ctx.mock_erc721.ownerOf(nft_id)) + .to.equal(owner_address); +} + +export async function testERC1155Withdraw( + manager: Contract, + test_ctx: TestContext +) : Promise { + const nft_id: number = 0; + const nft_amount: number = 2; + const owner_address: string = await test_ctx.accounts.owner.getAddress(); + + await expect(await manager.withdrawERC1155( + test_ctx.mock_erc1155.address, + nft_id, + nft_amount + )) + .to.emit(manager, "ERC1155Withdrawn") + .withArgs( + owner_address, + test_ctx.mock_erc1155.address, + nft_id, + nft_amount + ); + + expect(await test_ctx.mock_erc1155.balanceOf(owner_address, nft_id)) + .to.equal(nft_amount); +} + +export async function testERC721WithdrawNullAddress( + manager: Contract +) : Promise { + await expect(manager.withdrawERC721(constants.NULL_ADDRESS, 0)) + .to.be.revertedWithCustomError(manager, "NullAddressError"); +} + +export async function testERC721WithdrawInvalidId( + manager: Contract, + test_ctx: TestContext +) : Promise { + await expect(manager.withdrawERC721( + test_ctx.mock_erc721.address, + constants.ERC721_TOKEN_SUPPLY + )) + .to.be.revertedWith("ERC721: caller is not token owner or approved"); +} + +export async function testERC1155WithdrawNullAddress( + manager: Contract +) : Promise { + await expect(manager.withdrawERC1155(constants.NULL_ADDRESS, 0, 1)) + .to.be.revertedWithCustomError(manager, "NullAddressError"); +} + +export async function testERC1155WithdrawInvalidId( + manager: Contract, + test_ctx: TestContext +) : Promise { + await expect(manager.withdrawERC1155( + test_ctx.mock_erc1155.address, + constants.ERC1155_TOKEN_SUPPLY, + 1 + )) + .to.be.revertedWith("ERC1155: insufficient balance for transfer"); +} + +export async function testERC1155WithdrawInvalidAmount( + manager: Contract, + test_ctx: TestContext +) : Promise { + await expect(manager.withdrawERC1155(test_ctx.mock_erc1155.address, 0, 0)) + .to.be.revertedWithCustomError(manager, "AmountError"); + + await expect(manager.withdrawERC1155( + test_ctx.mock_erc1155.address, + 0, + constants.ERC1155_TOKEN_AMOUNT + 1 + )) + .to.be.revertedWith("ERC1155: insufficient balance for transfer"); +} diff --git a/test/common/UtilsCommon.ts b/test/common/UtilsCommon.ts new file mode 100644 index 0000000..6e25135 --- /dev/null +++ b/test/common/UtilsCommon.ts @@ -0,0 +1,210 @@ +import { Contract, ContractFactory, Signer } from "ethers"; +import hre from "hardhat"; +// Project +import * as constants from "./Constants"; + + +// +// Interfaces +// + +export interface Accounts { + signers: Signer[]; + owner: Signer; +} + +export interface TestContext { + accounts: Accounts; + mock_erc20: Contract; + mock_erc721: Contract; + mock_erc1155: Contract; + payment_erc20_address: string; + user_1_account: Signer; + user_1_address: string; + user_2_account: Signer; + user_2_address: string; + user_3_account: Signer; + user_3_address: string; +} + +// +// Exported functions +// + +export async function initTestContext() : Promise { + const accounts: Accounts = await initAccounts(); + + const payment_erc20_address: string = await accounts.owner.getAddress(); + const mock_erc20: Contract = await deployMockERC20TokenContract(); + const mock_erc721: Contract = await deployMockERC721TokenContract(); + const mock_erc1155: Contract = await deployMockERC1155TokenContract(); + const user_1_account: Signer = accounts.signers[0]; + const user_1_address: string = await user_1_account.getAddress(); + const user_2_account: Signer = accounts.signers[1]; + const user_2_address: string = await user_2_account.getAddress(); + const user_3_account: Signer = accounts.signers[2]; + const user_3_address: string = await user_3_account.getAddress(); + + return { + accounts, + mock_erc20, + mock_erc721, + mock_erc1155, + payment_erc20_address, + user_1_account, + user_1_address, + user_2_account, + user_2_address, + user_3_account, + user_3_address + }; +} + +export async function initTokens( + accounts: Accounts, + nftTargetAddr: string, + erc20Token: Contract, + erc721Token: Contract, + erc1155Token: Contract, + user_1: Signer, + user_2: Signer, + user_3: Signer +) : Promise { + const owner_address = await accounts.owner.getAddress(); + const user_1_address = await user_1.getAddress(); + const user_2_address = await user_2.getAddress(); + const user_3_address = await user_3.getAddress(); + + // Approve ERC20 tokens for users + await erc20Token + .connect(user_1) + .approve(nftTargetAddr, constants.UINT256_MAX); + await erc20Token + .connect(user_2) + .approve(nftTargetAddr, constants.UINT256_MAX); + await erc20Token + .connect(user_3) + .approve(nftTargetAddr, constants.UINT256_MAX); + // Transfer ERC20 tokens to users + await erc20Token + .connect(accounts.owner) + .transfer(user_1_address, constants.ERC20_TOKEN_SUPPLY / 2); + await erc20Token + .connect(accounts.owner) + .transfer(user_2_address, constants.ERC20_TOKEN_SUPPLY / 2); + // Mint ERC721 tokens (one to the owner to test for ownership) + for (let i = 0; i < constants.ERC721_TOKEN_SUPPLY - 1; i++) { + await erc721Token.mintTo(nftTargetAddr, i); + } + await erc721Token.mintTo(owner_address, constants.ERC721_TOKEN_SUPPLY); + // Mint ERC1155 tokens + for (let i = 0; i < constants.ERC1155_TOKEN_SUPPLY; i++) { + await erc1155Token.mint(nftTargetAddr, i, constants.ERC1155_TOKEN_AMOUNT); + } +} + +export async function deployProxyContract( + logicInstance: Contract, + paymentERC20Address: string +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("ERC1967Proxy"); + const instance: Contract = await contract_factory + .deploy( + logicInstance.address, + logicInstance.interface.encodeFunctionData( + "init", + [paymentERC20Address] + ) + ); + await instance.deployed(); + + return instance; +} + +export async function getMockERC20TokenContractAt( + address: string +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("MockERC20Token"); + return contract_factory.attach(address); +} + +export async function getMockERC721TokenContractAt( + address: string +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("MockERC721Token"); + return contract_factory.attach(address); +} + +export async function getMockERC1155TokenContractAt( + address: string +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("MockERC1155Token"); + return contract_factory.attach(address); +} + +export async function deployMockERC20ReceiverContract() : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("MockERC20Receiver"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +export async function deployMockERC20ReceiverRetValErrContract() : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("MockERC20ReceiverRetValErr"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +export async function deployMockERC20ReceiverNotImplContract() : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("MockERC20ReceiverNotImpl"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +// +// Not exported functions +// + +async function initAccounts() : Promise { + const all_signers: Signer[] = await hre.ethers.getSigners(); + + const owner: Signer = all_signers[0]; + const signers: Signer[] = []; + for (let i = 1; i < all_signers.length; i++) { + signers.push(all_signers[i]) + } + + return { + owner, + signers, + }; +} + +async function deployMockERC20TokenContract() : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("MockERC20Token"); + const instance: Contract = await contract_factory.deploy(constants.ERC20_TOKEN_SUPPLY); + await instance.deployed(); + + return instance; +} + +async function deployMockERC721TokenContract() : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("MockERC721Token"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +async function deployMockERC1155TokenContract() : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("MockERC1155Token"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} diff --git a/test/redeemer/NftsRedeemer.Access.ts b/test/redeemer/NftsRedeemer.Access.ts new file mode 100644 index 0000000..5d9f5b4 --- /dev/null +++ b/test/redeemer/NftsRedeemer.Access.ts @@ -0,0 +1,58 @@ +import { expect } from "chai"; +import { Signer } from "ethers"; +// Project +import * as constants from "../common/Constants"; +import { RedeemerTestContext, initRedeemerTestContext } from "./UtilsRedeemer"; + + +describe("NftsRedeemer.Access", () => { + let test_ctx: RedeemerTestContext; + + beforeEach(async () => { + test_ctx = await initRedeemerTestContext(); + }); + + it("should revert if functions are not called by the owner", async () => { + const not_owner_account: Signer = test_ctx.accounts.signers[0]; + + await expect(test_ctx.redeemer.connect(not_owner_account).upgradeTo(constants.NULL_ADDRESS)) + .to.be.revertedWith("Ownable: caller is not the owner"); + await expect(test_ctx.redeemer.connect(not_owner_account).upgradeToAndCall(constants.NULL_ADDRESS, constants.EMPTY_BYTES)) + .to.be.revertedWith("Ownable: caller is not the owner"); + + await expect(test_ctx.redeemer.connect(not_owner_account).setPaymentERC20Address(constants.NULL_ADDRESS)) + .to.be.revertedWith("Ownable: caller is not the owner"); + await expect(test_ctx.redeemer.connect(not_owner_account).removeRedeem(test_ctx.user_1_address)) + .to.be.revertedWith("Ownable: caller is not the owner"); + + await expect(test_ctx.redeemer.connect(not_owner_account).createERC721Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + await expect(test_ctx.redeemer.connect(not_owner_account).createERC1155Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc1155.address, + 0, + 1, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + + await expect(test_ctx.redeemer.connect(not_owner_account).withdrawERC721( + test_ctx.mock_erc721.address, + 0 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + await expect(test_ctx.redeemer.connect(not_owner_account).withdrawERC1155( + test_ctx.mock_erc1155.address, + 0, + 0 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + }); +}); diff --git a/test/redeemer/NftsRedeemer.Create.ts b/test/redeemer/NftsRedeemer.Create.ts new file mode 100644 index 0000000..901bb20 --- /dev/null +++ b/test/redeemer/NftsRedeemer.Create.ts @@ -0,0 +1,305 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +// Project +import * as constants from "../common/Constants"; +import { Redeem, RedeemerTestContext, initRedeemerTestContextAndToken } from "./UtilsRedeemer"; + + +async function testCreate( + redeemer: Contract, + mock_nft: Contract, + mock_erc20: Contract, + user_address: string +) : Promise { + const is_erc721: boolean = await mock_nft.supportsInterface(constants.ERC721_INTERFACE_ID); + const erc20_amount: number = 1; + const nft_amount: number = is_erc721 ? 0 : 1; + const nft_id: number = 0; + + if (is_erc721) { + await expect(await redeemer.createERC721Redeem( + user_address, + mock_nft.address, + nft_id, + mock_erc20.address, + erc20_amount + )) + .to.emit(redeemer, "RedeemCreated") + .withArgs( + user_address, + mock_nft.address, + nft_id, + 0, + mock_erc20.address, + erc20_amount + ); + } + else { + await expect(await redeemer.createERC1155Redeem( + user_address, + mock_nft.address, + nft_id, + nft_amount, + mock_erc20.address, + erc20_amount + )) + .to.emit(redeemer, "RedeemCreated") + .withArgs( + user_address, + mock_nft.address, + nft_id, + nft_amount, + mock_erc20.address, + erc20_amount + ); + } + + expect(await redeemer.Redeemers(mock_nft.address, nft_id)) + .to.equal(user_address); + + const redeem_data: Redeem = await redeemer.Redeems(user_address); + expect(redeem_data.nftContract).to.equal(mock_nft.address); + expect(redeem_data.nftId).to.equal(nft_id); + expect(redeem_data.nftAmount).to.equal(nft_amount); + expect(redeem_data.erc20Contract).to.equal(mock_erc20.address); + expect(redeem_data.erc20Amount).to.equal(erc20_amount); + expect(redeem_data.isActive).to.equal(true); + + expect(await redeemer["isRedeemActive(address)"](user_address)) + .to.equal(true); + expect(await redeemer["isRedeemActive(address,uint256)"](redeem_data.nftContract, redeem_data.nftId)) + .to.equal(true); +} + +describe("NftsRedeemer.Create", () => { + let test_ctx: RedeemerTestContext; + + beforeEach(async () => { + test_ctx = await initRedeemerTestContextAndToken(); + }); + + it("should create a token to be redeemed", async () => { + await testCreate( + test_ctx.redeemer, + test_ctx.mock_erc721, + test_ctx.mock_erc20, + test_ctx.user_1_address + ); + }); + + it("should create a ERC1155 token to be redeemed", async () => { + await testCreate( + test_ctx.redeemer, + test_ctx.mock_erc1155, + test_ctx.mock_erc20, + test_ctx.user_1_address + ); + }); + + it("should revert if creating a ERC721 token redeem with null addresses", async () => { + await expect(test_ctx.redeemer.createERC721Redeem( + constants.NULL_ADDRESS, + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "NullAddressError"); + + await expect(test_ctx.redeemer.createERC721Redeem( + test_ctx.user_1_address, + constants.NULL_ADDRESS, + 0, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "NullAddressError"); + + await expect(test_ctx.redeemer.createERC721Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc721.address, + 0, + constants.NULL_ADDRESS, + 1 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "NullAddressError"); + }); + + it("should revert if creating a ERC1155 token redeem with null addresses", async () => { + await expect(test_ctx.redeemer.createERC1155Redeem( + constants.NULL_ADDRESS, + test_ctx.mock_erc1155.address, + 0, + 1, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "NullAddressError"); + + await expect(test_ctx.redeemer.createERC1155Redeem( + test_ctx.user_1_address, + constants.NULL_ADDRESS, + 0, + 1, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "NullAddressError"); + + await expect(test_ctx.redeemer.createERC1155Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc1155.address, + 0, + 1, + constants.NULL_ADDRESS, + 1 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "NullAddressError"); + }); + + it("should revert if creating a ERC721 token redeem more than once for the same user", async () => { + await test_ctx.redeemer.createERC721Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 0 + ); + await expect(test_ctx.redeemer.createERC721Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "RedeemAlreadyCreatedError") + .withArgs(test_ctx.user_1_address); + }); + + it("should revert if creating a ERC1155 token redeem more than once for the same user", async () => { + await test_ctx.redeemer.createERC1155Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc1155.address, + 0, + 1, + test_ctx.mock_erc20.address, + 0 + ); + await expect(test_ctx.redeemer.createERC1155Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc1155.address, + 0, + 1, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "RedeemAlreadyCreatedError") + .withArgs(test_ctx.user_1_address); + }); + + it("should revert if creating a ERC721 token redeem that is already created for another user", async () => { + await test_ctx.redeemer.createERC721Redeem( + test_ctx.user_2_address, + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 0 + ); + await expect(test_ctx.redeemer.createERC721Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "RedeemAlreadyCreatedError") + .withArgs(test_ctx.user_2_address); + }); + + it("should revert if creating a ERC1155 token redeem that is already created for another user", async () => { + await test_ctx.redeemer.createERC1155Redeem( + test_ctx.user_2_address, + test_ctx.mock_erc1155.address, + 0, + 1, + test_ctx.mock_erc20.address, + 0 + ); + await expect(test_ctx.redeemer.createERC1155Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc1155.address, + 0, + 1, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "RedeemAlreadyCreatedError") + .withArgs(test_ctx.user_2_address); + }); + + it("should revert if creating a ERC721 token redeem with an invalid ID", async () => { + const nft_id: number = constants.ERC721_TOKEN_SUPPLY; + + await expect(test_ctx.redeemer.createERC721Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc721.address, + nft_id, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "NftError") + .withArgs(test_ctx.mock_erc721.address, nft_id); + }); + + it("should revert if creating a ERC721 token redeem with a token owned by another address", async () => { + const nft_id: number = constants.ERC721_TOKEN_SUPPLY - 1; + + await expect(test_ctx.redeemer.createERC721Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc721.address, + nft_id, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "NftError") + .withArgs(test_ctx.mock_erc721.address, nft_id); + }); + + it("should revert if creating a ERC1155 token redeem with an invalid ID", async () => { + const nft_id: number = constants.ERC1155_TOKEN_SUPPLY; + + await expect(test_ctx.redeemer.createERC1155Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc1155.address, + nft_id, + 1, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "NftError") + .withArgs(test_ctx.mock_erc1155.address, nft_id); + }); + + it("should revert if creating a ERC1155 token redeem with an invalid amount", async () => { + await expect(test_ctx.redeemer.createERC1155Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc1155.address, + 0, + 0, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "AmountError"); + + await expect(test_ctx.redeemer.createERC1155Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc1155.address, + 0, + constants.ERC1155_TOKEN_AMOUNT + 1, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.redeemer, "NftError") + .withArgs(test_ctx.mock_erc1155.address, 0); + }); +}); diff --git a/test/redeemer/NftsRedeemer.Deploy.ts b/test/redeemer/NftsRedeemer.Deploy.ts new file mode 100644 index 0000000..42dceff --- /dev/null +++ b/test/redeemer/NftsRedeemer.Deploy.ts @@ -0,0 +1,63 @@ +import { expect } from "chai"; +import { Contract, ContractFactory } from "ethers"; +import hre from "hardhat"; +// Project +import * as constants from "../common/Constants"; +import { + RedeemerTestContext, + deployRedeemerContract, deployRedeemerUpgradedContract, + getRedeemerUpgradedContractAt, initRedeemerTestContext +} from "./UtilsRedeemer"; + + +describe("NftsRedeemer.Deploy", () => { + let test_ctx: RedeemerTestContext; + + beforeEach(async () => { + test_ctx = await initRedeemerTestContext(); + }); + + it("should be constructed correctly", async () => { + const owner_address: string = await test_ctx.accounts.owner.getAddress(); + + expect(await test_ctx.redeemer.owner()).to.equal(owner_address); + expect(await test_ctx.redeemer.paymentERC20Address()).to.equal(test_ctx.payment_erc20_address); + }); + + it("should upgrade the logic", async () => { + const new_logic: Contract = await deployRedeemerUpgradedContract(); + + await expect(await test_ctx.redeemer.upgradeTo(new_logic.address)) + .not.to.be.reverted; + + test_ctx.redeemer = await getRedeemerUpgradedContractAt(test_ctx.redeemer.address); // Update ABI + expect(await test_ctx.redeemer.isUpgraded()) + .to.equal(true); + }); + + it("should revert if initializing more than once", async () => { + await expect(test_ctx.redeemer.init(constants.NULL_ADDRESS)) + .to.be.revertedWith("Initializable: contract is already initialized"); + }); + + it("should revert if initializing the logic contract without a proxy", async () => { + const nft: Contract = await deployRedeemerContract(); + await expect(nft.init(constants.NULL_ADDRESS)) + .to.be.revertedWith("Initializable: contract is already initialized"); + }); + + it("should revert if initializing with a null address", async () => { + const redeemer_logic_instance: Contract = await deployRedeemerContract(); + const proxy_contract_factory: ContractFactory = await hre.ethers.getContractFactory("ERC1967Proxy"); + await expect(proxy_contract_factory + .deploy( + redeemer_logic_instance.address, + redeemer_logic_instance.interface.encodeFunctionData( + "init", + [constants.NULL_ADDRESS] + ) + ) + ) + .to.be.revertedWithCustomError(test_ctx.redeemer, "NullAddressError"); + }); +}); diff --git a/test/redeemer/NftsRedeemer.ERC20Receiver.ts b/test/redeemer/NftsRedeemer.ERC20Receiver.ts new file mode 100644 index 0000000..5405323 --- /dev/null +++ b/test/redeemer/NftsRedeemer.ERC20Receiver.ts @@ -0,0 +1,42 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +// Project +import { + deployMockERC20ReceiverContract, deployMockERC20ReceiverRetValErrContract, deployMockERC20ReceiverNotImplContract +} from "../common/UtilsCommon"; +import { RedeemerTestContext, initRedeemerTestContextAndCreate } from "./UtilsRedeemer"; + + +describe("NftsRedeemer.ERC20Receiver", () => { + let test_ctx: RedeemerTestContext; + + beforeEach(async () => { + test_ctx = await initRedeemerTestContextAndCreate(); + }); + + it("should call the onERC20Received function if the payment ERC20 address is a contract", async () => { + const erc20_receiver: Contract = await deployMockERC20ReceiverContract(); + expect(await erc20_receiver.received()).to.equal(false); + + await test_ctx.redeemer.setPaymentERC20Address(erc20_receiver.address); + await test_ctx.redeemer.connect(test_ctx.accounts.signers[0]).redeemToken(); + + expect(await erc20_receiver.received()).to.equal(true); + }); + + it("should revert if the onERC20Received function returns the wrong value", async () => { + const erc20_receiver: Contract = await deployMockERC20ReceiverRetValErrContract(); + + await test_ctx.redeemer.setPaymentERC20Address(erc20_receiver.address); + await expect(test_ctx.redeemer.connect(test_ctx.accounts.signers[0]).redeemToken()) + .to.be.revertedWithCustomError(test_ctx.redeemer, "IERC20ReceiverRetValError"); + }); + + it("should revert if the onERC20Received function is not implemented", async () => { + const erc20_receiver: Contract = await deployMockERC20ReceiverNotImplContract(); + + await test_ctx.redeemer.setPaymentERC20Address(erc20_receiver.address); + await expect(test_ctx.redeemer.connect(test_ctx.accounts.signers[0]).redeemToken()) + .to.be.revertedWithCustomError(test_ctx.redeemer, "IERC20ReceiverNotImplError"); + }); +}); diff --git a/test/redeemer/NftsRedeemer.PaymentERC20Address.ts b/test/redeemer/NftsRedeemer.PaymentERC20Address.ts new file mode 100644 index 0000000..fb6b877 --- /dev/null +++ b/test/redeemer/NftsRedeemer.PaymentERC20Address.ts @@ -0,0 +1,25 @@ +// Project +import { testPaymentERC20AddressSet, testPaymentERC20AddressNullAddress } from "../common/TestPaymentERC20Address"; +import { RedeemerTestContext, initRedeemerTestContext } from "./UtilsRedeemer"; + + +describe("NftsRedeemer.PaymentERC20Address", () => { + let test_ctx: RedeemerTestContext; + + beforeEach(async () => { + test_ctx = await initRedeemerTestContext(); + }); + + it("should set the payment wallet", async () => { + await testPaymentERC20AddressSet( + test_ctx.redeemer, + test_ctx.user_1_address + ); + }); + + it("should revert if setting a payment wallet with null address", async () => { + await testPaymentERC20AddressNullAddress( + test_ctx.redeemer + ); + }); +}); diff --git a/test/redeemer/NftsRedeemer.Redeem.ts b/test/redeemer/NftsRedeemer.Redeem.ts new file mode 100644 index 0000000..1f448bf --- /dev/null +++ b/test/redeemer/NftsRedeemer.Redeem.ts @@ -0,0 +1,150 @@ +import { expect } from "chai"; +import { BigNumber, Contract, Signer } from "ethers"; +// Project +import * as constants from "../common/Constants"; +import { + getMockERC20TokenContractAt, getMockERC721TokenContractAt, getMockERC1155TokenContractAt +} from "../common/UtilsCommon"; +import { Redeem, RedeemerTestContext, initRedeemerTestContextAndCreate } from "./UtilsRedeemer"; + + +async function testRedeemToken( + redeemer: Contract, + mock_nft: Contract, + owner: Signer, + user_account: Signer +) : Promise { + const is_erc721: boolean = await mock_nft.supportsInterface(constants.ERC721_INTERFACE_ID); + const user_address: string = await user_account.getAddress(); + const redeem_data_before: Redeem = await redeemer.Redeems(user_address); + const mock_erc20: Contract = await getMockERC20TokenContractAt(redeem_data_before.erc20Contract); + + const owner_address: string = await owner.getAddress(); + const initial_owner_balance: BigNumber = await mock_erc20.balanceOf(owner_address); + const initial_user_balance: BigNumber = await mock_erc20.balanceOf(user_address); + + await expect(redeemer.connect(user_account).redeemToken()) + .to.emit(redeemer, "RedeemCompleted") + .withArgs( + user_address, + redeem_data_before.nftContract, + redeem_data_before.nftId, + redeem_data_before.nftAmount, + redeem_data_before.erc20Contract, + redeem_data_before.erc20Amount + ); + + expect(await redeemer.Redeemers(redeem_data_before.nftContract, redeem_data_before.nftId)) + .to.equal(constants.NULL_ADDRESS); + + // Check data + const redeem_data_after = await redeemer.Redeems(user_address); + expect(redeem_data_after.nftContract).to.equal(redeem_data_before.nftContract); + expect(redeem_data_after.nftId).to.equal(redeem_data_before.nftId); + expect(redeem_data_after.nftAmount).to.equal(redeem_data_before.nftAmount); + expect(redeem_data_after.erc20Contract).to.equal(redeem_data_before.erc20Contract); + expect(redeem_data_after.erc20Amount).to.equal(redeem_data_before.erc20Amount); + expect(redeem_data_after.isActive).to.equal(false); + + expect(await redeemer["isRedeemActive(address)"](user_address)) + .to.equal(false); + expect(await redeemer["isRedeemActive(address,uint256)"](redeem_data_after.nftContract, redeem_data_after.nftId)) + .to.equal(false); + + // Check token transfers + expect(await mock_erc20.balanceOf(owner_address)) + .to.equal(initial_owner_balance.add(redeem_data_after.erc20Amount)); + expect(await mock_erc20.balanceOf(user_address)) + .to.equal(initial_user_balance.sub(redeem_data_after.erc20Amount)); + + if (is_erc721) { + const mock_erc721: Contract = await getMockERC721TokenContractAt(redeem_data_after.nftContract); + expect(await mock_erc721.ownerOf(redeem_data_after.nftId)) + .to.equal(user_address); + } + else { + const mock_erc1155: Contract = await getMockERC1155TokenContractAt(redeem_data_after.nftContract); + expect(await mock_erc1155.balanceOf(user_address, redeem_data_after.nftId)) + .to.equal(redeem_data_after.nftAmount); + } +} + +async function testNotEnoughBalance( + redeemer: Contract, + mock_nft: Contract, + mock_erc20: Contract, + user_account: Signer, +) : Promise { + const is_erc721: boolean = await mock_nft.supportsInterface(constants.ERC721_INTERFACE_ID); + const user_address: string = await user_account.getAddress(); + + if (is_erc721) { + await redeemer.createERC721Redeem( + user_address, + mock_nft.address, + 2, + mock_erc20.address, + constants.ERC20_TOKEN_SUPPLY + ); + } + else { + await redeemer.createERC1155Redeem( + user_address, + mock_nft.address, + 1, + 1, + mock_erc20.address, + constants.ERC20_TOKEN_SUPPLY + ); + } + + await expect(redeemer.connect(user_account).redeemToken()) + .to.be.revertedWith("ERC20: transfer amount exceeds balance"); +} + +describe("NftsRedeemer.Redeem", () => { + let test_ctx: RedeemerTestContext; + + beforeEach(async () => { + test_ctx = await initRedeemerTestContextAndCreate(); + }); + + it("should allow a user to redeem a token", async () => { + await testRedeemToken( + test_ctx.redeemer, + test_ctx.mock_erc721, + test_ctx.accounts.owner, + test_ctx.user_1_account + ); + await testRedeemToken( + test_ctx.redeemer, + test_ctx.mock_erc1155, + test_ctx.accounts.owner, + test_ctx.user_2_account + ); + }); + + it("should revert if redeeming a ERC721 token with not enough balance", async () => { + await testNotEnoughBalance( + test_ctx.redeemer, + test_ctx.mock_erc721, + test_ctx.mock_erc20, + test_ctx.user_3_account + ); + }); + + it("should revert if redeeming a ERC1155 token with not enough balance", async () => { + await testNotEnoughBalance( + test_ctx.redeemer, + test_ctx.mock_erc1155, + test_ctx.mock_erc20, + test_ctx.user_3_account + ); + }); + + it("should revert if redeeming a not created token", async () => { + await expect(test_ctx.redeemer.connect(test_ctx.user_3_account).redeemToken()) + .to.be.revertedWithCustomError(test_ctx.redeemer, "RedeemNotCreatedError") + .withArgs(test_ctx.user_3_address); + }); +}); diff --git a/test/redeemer/NftsRedeemer.Remove.ts b/test/redeemer/NftsRedeemer.Remove.ts new file mode 100644 index 0000000..5b92db7 --- /dev/null +++ b/test/redeemer/NftsRedeemer.Remove.ts @@ -0,0 +1,65 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +// Project +import * as constants from "../common/Constants"; +import { Redeem, RedeemerTestContext, initRedeemerTestContextAndCreate } from "./UtilsRedeemer"; + + +async function testRemove( + redeemer: Contract, + mock_token: Contract, + user_address: string +) : Promise { + const redeem_data_before = await redeemer.Redeems(user_address); + + await expect(await redeemer.removeRedeem(user_address)) + .to.emit(redeemer, "RedeemRemoved") + .withArgs( + user_address, + redeem_data_before.nftContract, + redeem_data_before.nftId + ); + + expect(await redeemer.Redeemers(mock_token.address, redeem_data_before.nftId)) + .to.equal(constants.NULL_ADDRESS); + + const redeem_data_after: Redeem = await redeemer.Redeems(user_address); + expect(redeem_data_after.nftContract).to.equal(redeem_data_before.nftContract); + expect(redeem_data_after.nftId).to.equal(redeem_data_before.nftId); + expect(redeem_data_after.nftAmount).to.equal(redeem_data_before.nftAmount); + expect(redeem_data_after.erc20Contract).to.equal(redeem_data_before.erc20Contract); + expect(redeem_data_after.erc20Amount).to.equal(redeem_data_before.erc20Amount); + expect(redeem_data_after.isActive).to.equal(false); +} + +describe("NftsRedeemer.Remove", () => { + let test_ctx: RedeemerTestContext; + + beforeEach(async () => { + test_ctx = await initRedeemerTestContextAndCreate(); + }); + + it("should remove a token to be redeemed", async () => { + await testRemove( + test_ctx.redeemer, + test_ctx.mock_erc721, + test_ctx.user_1_address + ); + await testRemove( + test_ctx.redeemer, + test_ctx.mock_erc1155, + test_ctx.user_2_address + ); + }); + + it("should revert if removing a token redeem that is not created", async () => { + await expect(test_ctx.redeemer.removeRedeem(test_ctx.user_3_address)) + .to.be.revertedWithCustomError(test_ctx.redeemer, "RedeemNotCreatedError") + .withArgs(test_ctx.user_3_address); + }); + + it("should revert if removing a token redeem with null addresses", async () => { + await expect(test_ctx.redeemer.removeRedeem(constants.NULL_ADDRESS)) + .to.be.revertedWithCustomError(test_ctx.redeemer, "NullAddressError"); + }); +}); diff --git a/test/redeemer/NftsRedeemer.Withdraw.ts b/test/redeemer/NftsRedeemer.Withdraw.ts new file mode 100644 index 0000000..90273e6 --- /dev/null +++ b/test/redeemer/NftsRedeemer.Withdraw.ts @@ -0,0 +1,125 @@ +import { expect } from "chai"; +// Project +import * as constants from "../common/Constants"; +import { + testERC721Withdraw, testERC1155Withdraw, + testERC721WithdrawNullAddress, testERC721WithdrawInvalidId, + testERC1155WithdrawNullAddress, testERC1155WithdrawInvalidId, testERC1155WithdrawInvalidAmount +} from "../common/TestWithdraw"; +import { RedeemerTestContext, initRedeemerTestContextAndToken } from "./UtilsRedeemer"; + + +describe("NftsRedeemer.Withdraw", () => { + let test_ctx: RedeemerTestContext; + + beforeEach(async () => { + test_ctx = await initRedeemerTestContextAndToken(); + }); + + it("should withdraw a ERC721 token", async () => { + await testERC721Withdraw( + test_ctx.redeemer, + test_ctx + ); + }); + + it("should withdraw a ERC1155 token", async () => { + await testERC1155Withdraw( + test_ctx.redeemer, + test_ctx + ); + }); + + it("should revert if withdrawing a ERC721 token with null address", async () => { + await testERC721WithdrawNullAddress( + test_ctx.redeemer + ); + }); + + it("should revert if withdrawing a ERC721 token with an invalid token ID", async () => { + await testERC721WithdrawInvalidId( + test_ctx.redeemer, + test_ctx + ); + }); + + it("should revert if withdrawing a ERC1155 token with null address", async () => { + await testERC1155WithdrawNullAddress( + test_ctx.redeemer + ); + }); + + it("should revert if withdrawing a ERC1155 token with an invalid token ID", async () => { + await testERC1155WithdrawInvalidId( + test_ctx.redeemer, + test_ctx + ); + }); + + it("should revert if withdrawing a ERC1155 token with an invalid amount", async () => { + await testERC1155WithdrawInvalidAmount( + test_ctx.redeemer, + test_ctx + ); + }); + + it("should revert if withdrawing a ERC721 token that is already created for a user", async () => { + const nft_id: number = 0; + + await test_ctx.redeemer.createERC721Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc721.address, + nft_id, + test_ctx.mock_erc20.address, + 1 + ); + + await expect(test_ctx.redeemer.withdrawERC721( + test_ctx.mock_erc721.address, + nft_id + )) + .to.revertedWithCustomError(test_ctx.redeemer, "WithdrawError") + .withArgs( + test_ctx.mock_erc721.address, + nft_id + ); + }); + + it("should revert if withdrawing a ERC1155 token with a higher amount than the created one for a user", async () => { + const withdrawable_amount: number = 2; + const nft_amount: number = constants.ERC1155_TOKEN_AMOUNT - withdrawable_amount; + const nft_id: number = 0; + const owner_address: string = await test_ctx.accounts.owner.getAddress(); + + await test_ctx.redeemer.createERC1155Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc1155.address, + nft_id, + nft_amount, + test_ctx.mock_erc20.address, + 1 + ); + + // More than withdrawable amount + await expect(test_ctx.redeemer.withdrawERC1155( + test_ctx.mock_erc1155.address, + nft_id, + withdrawable_amount + 1 + )) + .to.revertedWithCustomError(test_ctx.redeemer, "WithdrawError") + .withArgs( + test_ctx.mock_erc1155.address, + nft_id + ); + + // Equal to withdrawable amount + await expect(test_ctx.redeemer.withdrawERC1155( + test_ctx.mock_erc1155.address, + nft_id, + withdrawable_amount + )) + .not.to.be.reverted; + expect(await test_ctx.mock_erc1155.balanceOf(owner_address, nft_id)) + .to.equal(withdrawable_amount); + }); +}); diff --git a/test/redeemer/UtilsRedeemer.ts b/test/redeemer/UtilsRedeemer.ts new file mode 100644 index 0000000..fa611fc --- /dev/null +++ b/test/redeemer/UtilsRedeemer.ts @@ -0,0 +1,133 @@ +import { BigNumber, Contract, ContractFactory } from "ethers"; +import hre from "hardhat"; +// Project +import * as constants from "../common/Constants"; +import { + Accounts, TestContext, + initTestContext, initTokens, deployProxyContract +} from "../common/UtilsCommon"; + + +// +// Interfaces +// + +export interface Redeem { + nftContract: string; + nftId: BigNumber; + nftAmount: BigNumber; + erc20Contract: string; + erc20Amount: BigNumber; + isActive: boolean; +} + +export interface RedeemerTestContext extends TestContext { + redeemer: Contract; +} + +// +// Exported functions +// + +export async function initRedeemerTestContext() : Promise { + const test_ctx: TestContext = await initTestContext(); + const redeemer: Contract = await deployRedeemerProxyContract(test_ctx.payment_erc20_address); + + return { + accounts: test_ctx.accounts, + mock_erc20: test_ctx.mock_erc20, + mock_erc721: test_ctx.mock_erc721, + mock_erc1155: test_ctx.mock_erc1155, + payment_erc20_address: test_ctx.payment_erc20_address, + redeemer: redeemer, + user_1_account: test_ctx.user_1_account, + user_1_address: test_ctx.user_1_address, + user_2_account: test_ctx.user_2_account, + user_2_address: test_ctx.user_2_address, + user_3_account: test_ctx.user_3_account, + user_3_address: test_ctx.user_3_address + }; +} + +export async function initRedeemerTestContextAndToken() : Promise { + const test_ctx: RedeemerTestContext = await initRedeemerTestContext(); + await initTokens( + test_ctx.accounts, + test_ctx.redeemer.address, + test_ctx.mock_erc20, + test_ctx.mock_erc721, + test_ctx.mock_erc1155, + test_ctx.user_1_account, + test_ctx.user_2_account, + test_ctx.user_3_account + ); + + return test_ctx; +} + +export async function initRedeemerTestContextAndCreate() : Promise { + const test_ctx: RedeemerTestContext = await initRedeemerTestContextAndToken(); + const erc20_amount: number = constants.ERC20_TOKEN_SUPPLY / 10; + const nft_id: number = 0; + + await test_ctx.redeemer.createERC721Redeem( + test_ctx.user_1_address, + test_ctx.mock_erc721.address, + nft_id, + test_ctx.mock_erc20.address, + erc20_amount + ); + await test_ctx.redeemer.createERC1155Redeem( + test_ctx.user_2_address, + test_ctx.mock_erc1155.address, + nft_id, + constants.ERC1155_TOKEN_AMOUNT, + test_ctx.mock_erc20.address, + erc20_amount + ); + + return test_ctx; +} + +export async function deployRedeemerContract() : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsRedeemer"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +export async function deployRedeemerUpgradedContract() : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsRedeemerUpgraded"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +export async function getRedeemerUpgradedContractAt( + address: string +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsRedeemerUpgraded"); + return contract_factory.attach(address); +} + +// +// Not exported functions +// + +async function getRedeemerContractAt( + address: string +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsRedeemer"); + return contract_factory.attach(address); +} + +async function deployRedeemerProxyContract( + paymentERC20Address: string +) : Promise { + const redeemer_logic_instance: Contract = await deployRedeemerContract(); + const proxy_instance: Contract = await deployProxyContract(redeemer_logic_instance, paymentERC20Address); + + return getRedeemerContractAt(proxy_instance.address); +} diff --git a/test/seller/NftsSeller.Access.ts b/test/seller/NftsSeller.Access.ts new file mode 100644 index 0000000..4dafe96 --- /dev/null +++ b/test/seller/NftsSeller.Access.ts @@ -0,0 +1,59 @@ +import { expect } from "chai"; +import { Signer } from "ethers"; +// Project +import * as constants from "../common/Constants"; +import { SellerTestContext, initSellerTestContext } from "./UtilsSeller"; + + +describe("NftsSeller.Access", () => { + let test_ctx: SellerTestContext; + + beforeEach(async () => { + test_ctx = await initSellerTestContext(); + }); + + it("should revert if functions are not called by the owner", async () => { + const not_owner_account: Signer = test_ctx.accounts.signers[0]; + + await expect(test_ctx.seller.connect(not_owner_account).upgradeTo(constants.NULL_ADDRESS)) + .to.be.revertedWith("Ownable: caller is not the owner"); + await expect(test_ctx.seller.connect(not_owner_account).upgradeToAndCall(constants.NULL_ADDRESS, constants.EMPTY_BYTES)) + .to.be.revertedWith("Ownable: caller is not the owner"); + + await expect(test_ctx.seller.connect(not_owner_account).setPaymentERC20Address(constants.NULL_ADDRESS)) + .to.be.revertedWith("Ownable: caller is not the owner"); + await expect(test_ctx.seller.connect(not_owner_account).removeSale( + test_ctx.mock_erc721.address, + 0 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + + await expect(test_ctx.seller.connect(not_owner_account).createERC721Sale( + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + await expect(test_ctx.seller.connect(not_owner_account).createERC1155Sale( + test_ctx.mock_erc721.address, + 0, + 1, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + + await expect(test_ctx.seller.connect(not_owner_account).withdrawERC721( + test_ctx.mock_erc721.address, + 0 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + await expect(test_ctx.seller.connect(not_owner_account).withdrawERC1155( + test_ctx.mock_erc1155.address, + 0, + 1 + )) + .to.be.revertedWith("Ownable: caller is not the owner"); + }); +}); diff --git a/test/seller/NftsSeller.Buy.ts b/test/seller/NftsSeller.Buy.ts new file mode 100644 index 0000000..e77a24e --- /dev/null +++ b/test/seller/NftsSeller.Buy.ts @@ -0,0 +1,199 @@ +import { expect } from "chai"; +import { BigNumber, Contract } from "ethers"; +// Project +import * as constants from "../common/Constants"; +import { getMockERC20TokenContractAt } from "../common/UtilsCommon"; +import { Sale, SellerTestContext, initSellerTestContextAndCreate } from "./UtilsSeller"; + + +describe("NftsSeller.Buy", () => { + let test_ctx: SellerTestContext; + + beforeEach(async () => { + test_ctx = await initSellerTestContextAndCreate(); + }); + + it("should allow a user to buy a created ERC721 token", async () => { + const nft_id: number = 0; + const owner_address: string = await test_ctx.accounts.owner.getAddress(); + const initial_owner_balance: BigNumber = await test_ctx.mock_erc20.balanceOf(owner_address); + const initial_user_balance: BigNumber = await test_ctx.mock_erc20.balanceOf(test_ctx.user_1_address); + const sale_data_before: Sale = await test_ctx.seller.Sales(test_ctx.mock_erc721.address, nft_id); + + await expect(await test_ctx.seller.connect(test_ctx.user_1_account).buyERC721( + test_ctx.mock_erc721.address, + nft_id + )) + .to.emit(test_ctx.seller, "SaleCompleted") + .withArgs( + test_ctx.user_1_address, + test_ctx.mock_erc721.address, + nft_id, + 0, + sale_data_before.erc20Contract, + sale_data_before.erc20Amount + ); + + // Check data + const sale_data_after: Sale = await test_ctx.seller.Sales(test_ctx.mock_erc721.address, nft_id); + expect(sale_data_after.nftAmount).to.equal(sale_data_before.nftAmount); + expect(sale_data_after.erc20Contract).to.equal(sale_data_before.erc20Contract); + expect(sale_data_after.erc20Amount).to.equal(sale_data_before.erc20Amount); + expect(sale_data_after.isActive).to.equal(false); + + expect(await test_ctx.seller.isSaleActive(test_ctx.mock_erc721.address, nft_id)) + .to.equal(false); + + // Check token transfers + const mock_erc20: Contract = await getMockERC20TokenContractAt(sale_data_before.erc20Contract); + + expect(await mock_erc20.balanceOf(owner_address)) + .to.equal(initial_owner_balance.add(sale_data_before.erc20Amount)); + expect(await mock_erc20.balanceOf(test_ctx.user_1_address)) + .to.equal(initial_user_balance.sub(sale_data_before.erc20Amount)); + expect(await test_ctx.mock_erc721.ownerOf(nft_id)) + .to.equal(test_ctx.user_1_address); + }); + + it("should allow a user to buy a created ERC1155 token", async () => { + const nft_amount_bought: number = constants.ERC1155_TOKEN_AMOUNT - 2; + const nft_id: number = 0; + const owner_address: string = await test_ctx.accounts.owner.getAddress(); + const initial_owner_balance: BigNumber = await test_ctx.mock_erc20.balanceOf(owner_address); + const initial_user_balance: BigNumber = await test_ctx.mock_erc20.balanceOf(test_ctx.user_1_address); + const sale_data_before: Sale = await test_ctx.seller.Sales(test_ctx.mock_erc1155.address, nft_id); + + await expect(await test_ctx.seller.connect(test_ctx.user_1_account).buyERC1155( + test_ctx.mock_erc1155.address, + nft_id, + nft_amount_bought + )) + .to.emit(test_ctx.seller, "SaleCompleted") + .withArgs( + test_ctx.user_1_address, + test_ctx.mock_erc1155.address, + nft_id, + nft_amount_bought, + sale_data_before.erc20Contract, + sale_data_before.erc20Amount + ); + + const token_price_1: BigNumber = sale_data_before.erc20Amount.mul(nft_amount_bought); + const mock_erc20: Contract = await getMockERC20TokenContractAt(sale_data_before.erc20Contract); + + // Check data, sale is still active because the bought token amount is less than the sold one + const sale_data_after_1: Sale = await test_ctx.seller.Sales(test_ctx.mock_erc1155.address, nft_id); + expect(sale_data_after_1.nftAmount).to.equal(sale_data_before.nftAmount.sub(nft_amount_bought)); + expect(sale_data_after_1.erc20Contract).to.equal(sale_data_before.erc20Contract); + expect(sale_data_after_1.erc20Amount).to.equal(sale_data_before.erc20Amount); + expect(sale_data_after_1.isActive).to.equal(true); + + expect(await test_ctx.seller.isSaleActive(test_ctx.mock_erc1155.address, nft_id)) + .to.equal(true); + + // Check token transfers + expect(await mock_erc20.balanceOf(owner_address)) + .to.equal(initial_owner_balance.add(token_price_1)); + expect(await mock_erc20.balanceOf(test_ctx.user_1_address)) + .to.equal(initial_user_balance.sub(token_price_1)); + expect(await test_ctx.mock_erc1155.balanceOf(test_ctx.user_1_address, nft_id)) + .to.equal(nft_amount_bought); + + // Buy again the remaining amount to reset the sale + const nft_amount_remaining: number = constants.ERC1155_TOKEN_AMOUNT - nft_amount_bought; + const token_price_2: BigNumber = sale_data_after_1.erc20Amount.mul(nft_amount_remaining); + + await test_ctx.seller.connect(test_ctx.user_1_account).buyERC1155( + test_ctx.mock_erc1155.address, + nft_id, + nft_amount_remaining + ); + + // Check data, sale is now inactive because the total token amount was bought + const sale_data_after_2: Sale = await test_ctx.seller.Sales(test_ctx.mock_erc1155.address, nft_id); + expect(sale_data_after_2.nftAmount).to.equal(0); + expect(sale_data_after_2.erc20Contract).to.equal(sale_data_before.erc20Contract); + expect(sale_data_after_2.erc20Amount).to.equal(sale_data_before.erc20Amount); + expect(sale_data_after_2.isActive).to.equal(false); + + expect(await test_ctx.seller.isSaleActive(test_ctx.mock_erc1155.address, nft_id)) + .to.equal(false); + + // Check token transfers + expect(await mock_erc20.balanceOf(owner_address)) + .to.equal(initial_owner_balance.add(token_price_1).add(token_price_2)); + expect(await mock_erc20.balanceOf(test_ctx.user_1_address)) + .to.equal(initial_user_balance.sub(token_price_1).sub(token_price_2)); + expect(await test_ctx.mock_erc1155.balanceOf(test_ctx.user_1_address, nft_id)) + .to.equal(nft_amount_bought + nft_amount_remaining); + }); + + it("should revert if buying a token with not enough balance", async () => { + await expect(test_ctx.seller.connect(test_ctx.user_3_account).buyERC721( + test_ctx.mock_erc721.address, + 0 + )) + .to.be.revertedWith("ERC20: transfer amount exceeds balance"); + + await expect(test_ctx.seller.connect(test_ctx.user_3_account).buyERC1155( + test_ctx.mock_erc1155.address, + 0, + 1 + )) + .to.be.revertedWith("ERC20: transfer amount exceeds balance"); + }); + + it("should revert if buying a ERC1155 token with invalid amount", async () => { + const nft_id: number = 0; + const sale_data: Sale = await test_ctx.seller.Sales(test_ctx.mock_erc1155.address, nft_id); + + await expect(test_ctx.seller.connect(test_ctx.user_1_account).buyERC1155( + test_ctx.mock_erc1155.address, + nft_id, + sale_data.nftAmount.add(1) + )) + .to.be.revertedWithCustomError(test_ctx.seller, "AmountError"); + }); + + it("should revert if buying a token with null address", async () => { + const nft_id: number = 0; + + await expect(test_ctx.seller.connect(test_ctx.user_1_account).buyERC721( + constants.NULL_ADDRESS, + nft_id + )) + .to.be.revertedWithCustomError(test_ctx.seller, "NullAddressError"); + + await expect(test_ctx.seller.connect(test_ctx.user_1_account).buyERC1155( + constants.NULL_ADDRESS, + nft_id, + 1 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "NullAddressError"); + }); + + it("should revert if buying a token whose sale that is not created ", async () => { + const nft_id: number = 1; + + await expect(test_ctx.seller.connect(test_ctx.user_1_account).buyERC721( + test_ctx.mock_erc721.address, + nft_id + )) + .to.be.revertedWithCustomError(test_ctx.seller, "SaleNotCreatedError") + .withArgs( + test_ctx.mock_erc721.address, + nft_id + ); + + await expect(test_ctx.seller.connect(test_ctx.user_1_account).buyERC1155( + test_ctx.mock_erc1155.address, + nft_id, + 1 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "SaleNotCreatedError") + .withArgs( + test_ctx.mock_erc1155.address, + nft_id + ); + }); +}); diff --git a/test/seller/NftsSeller.Create.ts b/test/seller/NftsSeller.Create.ts new file mode 100644 index 0000000..5943370 --- /dev/null +++ b/test/seller/NftsSeller.Create.ts @@ -0,0 +1,217 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +// Project +import * as constants from "../common/Constants"; +import { Sale, SellerTestContext, initSellerTestContextAndToken } from "./UtilsSeller"; + + +async function testCreate( + seller: Contract, + mock_nft: Contract, + mock_erc20: Contract +) : Promise { + const is_erc721: boolean = await mock_nft.supportsInterface(constants.ERC721_INTERFACE_ID); + const erc20_amount: number = 1; + const nft_amount: number = is_erc721 ? 0 : constants.ERC1155_TOKEN_AMOUNT; + const nft_id: number = 0; + + if (is_erc721) { + await expect(await seller.createERC721Sale( + mock_nft.address, + nft_id, + mock_erc20.address, + erc20_amount + )) + .to.emit(seller, "SaleCreated") + .withArgs( + mock_nft.address, + nft_id, + nft_amount, + mock_erc20.address, + erc20_amount + ); + } + else { + await expect(await seller.createERC1155Sale( + mock_nft.address, + nft_id, + nft_amount, + mock_erc20.address, + erc20_amount + )) + .to.emit(seller, "SaleCreated") + .withArgs( + mock_nft.address, + nft_id, + nft_amount, + mock_erc20.address, + erc20_amount + ); + + } + + const sale_data: Sale = await seller.Sales(mock_nft.address, nft_id); + expect(sale_data.nftAmount).to.equal(nft_amount); + expect(sale_data.erc20Contract).to.equal(mock_erc20.address); + expect(sale_data.erc20Amount).to.equal(erc20_amount); + expect(sale_data.isActive).to.equal(true); + + expect(await seller.isSaleActive(mock_nft.address, nft_id)) + .to.equal(true); +} + +describe("NftsSeller.Create", () => { + let test_ctx: SellerTestContext; + + beforeEach(async () => { + test_ctx = await initSellerTestContextAndToken(); + }); + + it("should create a token sale", async () => { + await testCreate( + test_ctx.seller, + test_ctx.mock_erc721, + test_ctx.mock_erc20 + ); + await testCreate( + test_ctx.seller, + test_ctx.mock_erc1155, + test_ctx.mock_erc20 + ); + }); + + it("should revert if creating a ERC721 token sale with null addresses", async () => { + await expect(test_ctx.seller.createERC721Sale( + constants.NULL_ADDRESS, + 0, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "NullAddressError"); + + await expect(test_ctx.seller.createERC721Sale( + test_ctx.mock_erc721.address, + 0, + constants.NULL_ADDRESS, + 1 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "NullAddressError"); + }); + + it("should revert if creating a ERC1155 token sale with null addresses", async () => { + await expect(test_ctx.seller.createERC1155Sale( + constants.NULL_ADDRESS, + 0, + 1, + test_ctx.mock_erc20.address, + 1 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "NullAddressError"); + + await expect(test_ctx.seller.createERC1155Sale( + test_ctx.mock_erc1155.address, + 0, + 1, + constants.NULL_ADDRESS, + 1 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "NullAddressError"); + }); + + it("should revert if creating a ERC721 token sale that is already existent", async () => { + await test_ctx.seller.createERC721Sale( + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 0 + ); + await expect(test_ctx.seller.createERC721Sale( + test_ctx.mock_erc721.address, + 0, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "SaleAlreadyCreatedError") + .withArgs(test_ctx.mock_erc721.address, 0); + }); + + it("should revert if creating a ERC1155 token sale that is already existent", async () => { + await test_ctx.seller.createERC1155Sale( + test_ctx.mock_erc1155.address, + 0, + 1, + test_ctx.mock_erc20.address, + 0 + ); + await expect(test_ctx.seller.createERC1155Sale( + test_ctx.mock_erc1155.address, + 0, + 1, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "SaleAlreadyCreatedError") + .withArgs(test_ctx.mock_erc1155.address, 0); + }); + + it("should revert if creating a ERC721 token sale with an invalid ID", async () => { + const nft_id: number = constants.ERC721_TOKEN_SUPPLY; + + await expect(test_ctx.seller.createERC721Sale( + test_ctx.mock_erc721.address, + nft_id, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "NftError") + .withArgs(test_ctx.mock_erc721.address, nft_id); + }); + + it("should revert if creating a ERC721 token sale with a token owned by another address", async () => { + const nft_id: number = constants.ERC721_TOKEN_SUPPLY - 1; + + await expect(test_ctx.seller.createERC721Sale( + test_ctx.mock_erc721.address, + nft_id, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "NftError") + .withArgs(test_ctx.mock_erc721.address, nft_id); + }); + + it("should revert if creating a ERC1155 token sale with an invalid ID", async () => { + const nft_id: number = constants.ERC1155_TOKEN_SUPPLY; + + await expect(test_ctx.seller.createERC1155Sale( + test_ctx.mock_erc1155.address, + nft_id, + 1, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "NftError") + .withArgs(test_ctx.mock_erc1155.address, nft_id); + }); + + it("should revert if creating a ERC1155 token sale with an invalid amount", async () => { + await expect(test_ctx.seller.createERC1155Sale( + test_ctx.mock_erc1155.address, + 0, + 0, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "AmountError"); + + await expect(test_ctx.seller.createERC1155Sale( + test_ctx.mock_erc1155.address, + 0, + constants.ERC1155_TOKEN_AMOUNT + 1, + test_ctx.mock_erc20.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "NftError") + .withArgs(test_ctx.mock_erc1155.address, 0); + }); +}); diff --git a/test/seller/NftsSeller.Deploy.ts b/test/seller/NftsSeller.Deploy.ts new file mode 100644 index 0000000..5795140 --- /dev/null +++ b/test/seller/NftsSeller.Deploy.ts @@ -0,0 +1,46 @@ +import { expect } from "chai"; +// Project +import * as constants from "../common/Constants"; +import { + SellerTestContext, + deploySellerContract, deploySellerUpgradedContract, + getSellerUpgradedContractAt, initSellerTestContext +} from "./UtilsSeller"; + + +describe("NftsSeller.Deploy", () => { + let test_ctx: SellerTestContext; + + beforeEach(async () => { + test_ctx = await initSellerTestContext(); + }); + + it("should be constructed correctly", async () => { + const owner_address: string = await test_ctx.accounts.owner.getAddress(); + + expect(await test_ctx.seller.owner()).to.equal(owner_address); + expect(await test_ctx.seller.paymentERC20Address()).to.equal(test_ctx.payment_erc20_address); + }); + + it("should upgrade the logic", async () => { + const new_logic = await deploySellerUpgradedContract(); + + await expect(await test_ctx.seller.upgradeTo(new_logic.address)) + .not.to.be.reverted; + + test_ctx.seller = await getSellerUpgradedContractAt(test_ctx.seller.address); // Update ABI + expect(await test_ctx.seller.isUpgraded()) + .to.equal(true); + }); + + it("should revert if initializing more than once", async () => { + await expect(test_ctx.seller.init(constants.NULL_ADDRESS)) + .to.be.revertedWith("Initializable: contract is already initialized"); + }); + + it("should revert if initializing the logic contract without a proxy", async () => { + const nft = await deploySellerContract(); + await expect(nft.init(constants.NULL_ADDRESS)) + .to.be.revertedWith("Initializable: contract is already initialized"); + }); +}); diff --git a/test/seller/NftsSeller.ERC20Receiver.ts b/test/seller/NftsSeller.ERC20Receiver.ts new file mode 100644 index 0000000..0dedda4 --- /dev/null +++ b/test/seller/NftsSeller.ERC20Receiver.ts @@ -0,0 +1,51 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +// Project +import { + deployMockERC20ReceiverContract, deployMockERC20ReceiverRetValErrContract, deployMockERC20ReceiverNotImplContract +} from "../common/UtilsCommon"; +import { SellerTestContext, initSellerTestContextAndCreate } from "./UtilsSeller"; + + +describe("NftsSeller.ERC20Receiver", () => { + let test_ctx: SellerTestContext; + + beforeEach(async () => { + test_ctx = await initSellerTestContextAndCreate(); + }); + + it("should call the onERC20Received function if the payment ERC20 address is a contract", async () => { + const erc20_receiver: Contract = await deployMockERC20ReceiverContract(); + expect(await erc20_receiver.received()).to.equal(false); + + await test_ctx.seller.setPaymentERC20Address(erc20_receiver.address); + await test_ctx.seller.connect(test_ctx.accounts.signers[0]).buyERC721( + test_ctx.mock_erc721.address, + 0 + ); + + expect(await erc20_receiver.received()).to.equal(true); + }); + + it("should revert if the onERC20Received function returns the wrong value", async () => { + const erc20_receiver: Contract = await deployMockERC20ReceiverRetValErrContract(); + + await test_ctx.seller.setPaymentERC20Address(erc20_receiver.address); + await expect(test_ctx.seller.connect(test_ctx.accounts.signers[0]).buyERC721( + test_ctx.mock_erc721.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "IERC20ReceiverRetValError"); + }); + + it("should revert if the onERC20Received function is not implemented", async () => { + const erc20_receiver: Contract = await deployMockERC20ReceiverNotImplContract(); + + await test_ctx.seller.setPaymentERC20Address(erc20_receiver.address); + await expect(test_ctx.seller.connect(test_ctx.accounts.signers[0]).buyERC721( + test_ctx.mock_erc721.address, + 0 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "IERC20ReceiverNotImplError"); + }); +}); diff --git a/test/seller/NftsSeller.PaymentERC20Address.ts b/test/seller/NftsSeller.PaymentERC20Address.ts new file mode 100644 index 0000000..d8f101a --- /dev/null +++ b/test/seller/NftsSeller.PaymentERC20Address.ts @@ -0,0 +1,25 @@ +// Project +import { testPaymentERC20AddressSet, testPaymentERC20AddressNullAddress } from "../common/TestPaymentERC20Address"; +import { SellerTestContext, initSellerTestContext } from "./UtilsSeller"; + + +describe("NftsSeller.PaymentERC20Address", () => { + let test_ctx: SellerTestContext; + + beforeEach(async () => { + test_ctx = await initSellerTestContext(); + }); + + it("should set the payment wallet", async () => { + await testPaymentERC20AddressSet( + test_ctx.seller, + test_ctx.user_1_address + ); + }); + + it("should revert if setting a payment wallet with null address", async () => { + await testPaymentERC20AddressNullAddress( + test_ctx.seller + ); + }); +}); diff --git a/test/seller/NftsSeller.Remove.ts b/test/seller/NftsSeller.Remove.ts new file mode 100644 index 0000000..dbabfb1 --- /dev/null +++ b/test/seller/NftsSeller.Remove.ts @@ -0,0 +1,82 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +// Project +import * as constants from "../common/Constants"; +import { Sale, SellerTestContext, initSellerTestContextAndCreate } from "./UtilsSeller"; + + +async function testRemove( + seller: Contract, + mock_token: Contract +) : Promise { + const nft_id: number = 0; + const sale_data_before: Sale = await seller.Sales(mock_token.address, nft_id); + + await expect(await seller.removeSale( + mock_token.address, + nft_id + )) + .to.emit(seller, "SaleRemoved") + .withArgs( + mock_token.address, + nft_id + ); + + const sale_data_after: Sale = await seller.Sales(mock_token.address, nft_id); + expect(sale_data_after.nftAmount).to.equal(sale_data_after.nftAmount); + expect(sale_data_after.erc20Contract).to.equal(sale_data_before.erc20Contract); + expect(sale_data_after.erc20Amount).to.equal(sale_data_before.erc20Amount); + expect(sale_data_after.isActive).to.equal(false); +} + +async function testRemoveNotCreated( + seller: Contract, + mock_token: Contract +) : Promise { + const nft_id: number = 1; + + await expect(seller.removeSale( + mock_token.address, + nft_id + )) + .to.be.revertedWithCustomError(seller, "SaleNotCreatedError") + .withArgs( + mock_token.address, + nft_id + ); +} + +describe("NftsSeller.Remove", () => { + let test_ctx: SellerTestContext; + + beforeEach(async () => { + test_ctx = await initSellerTestContextAndCreate(); + }); + + it("should remove a token to be sold", async () => { + await testRemove( + test_ctx.seller, + test_ctx.mock_erc721 + ); + await testRemove( + test_ctx.seller, + test_ctx.mock_erc1155 + ); + }); + + it("should revert if removing a token sale that is not created", async () => { + await testRemoveNotCreated( + test_ctx.seller, + test_ctx.mock_erc721 + ); + await testRemoveNotCreated( + test_ctx.seller, + test_ctx.mock_erc1155 + ); + }); + + it("should revert if removing a token sale with null addresses", async () => { + await expect(test_ctx.seller.removeSale(constants.NULL_ADDRESS, 0)) + .to.be.revertedWithCustomError(test_ctx.seller, "NullAddressError"); + }); +}); diff --git a/test/seller/NftsSeller.Withdraw.ts b/test/seller/NftsSeller.Withdraw.ts new file mode 100644 index 0000000..87256ed --- /dev/null +++ b/test/seller/NftsSeller.Withdraw.ts @@ -0,0 +1,123 @@ +import { expect } from "chai"; +// Project +import * as constants from "../common/Constants"; +import { + testERC721Withdraw, testERC1155Withdraw, + testERC721WithdrawNullAddress, testERC721WithdrawInvalidId, + testERC1155WithdrawNullAddress, testERC1155WithdrawInvalidId, testERC1155WithdrawInvalidAmount +} from "../common/TestWithdraw"; +import { SellerTestContext, initSellerTestContextAndToken } from "./UtilsSeller"; + + +describe("NftsSeller.Withdraw", () => { + let test_ctx: SellerTestContext; + + beforeEach(async () => { + test_ctx = await initSellerTestContextAndToken(); + }); + + it("should withdraw a ERC721 token", async () => { + await testERC721Withdraw( + test_ctx.seller, + test_ctx + ); + }); + + it("should withdraw a ERC1155 token", async () => { + await testERC1155Withdraw( + test_ctx.seller, + test_ctx + ); + }); + + it("should revert if withdrawing a ERC721 token with null address", async () => { + await testERC721WithdrawNullAddress( + test_ctx.seller + ); + }); + + it("should revert if withdrawing a ERC721 token with an invalid token ID", async () => { + await testERC721WithdrawInvalidId( + test_ctx.seller, + test_ctx + ); + }); + + it("should revert if withdrawing a ERC1155 token with null address", async () => { + await testERC1155WithdrawNullAddress( + test_ctx.seller + ); + }); + + it("should revert if withdrawing a ERC1155 token with an invalid token ID", async () => { + await testERC1155WithdrawInvalidId( + test_ctx.seller, + test_ctx + ); + }); + + it("should revert if withdrawing a ERC1155 token with an invalid amount", async () => { + await testERC1155WithdrawInvalidAmount( + test_ctx.seller, + test_ctx + ); + }); + + it("should revert if withdrawing a ERC721 token whose sale is created", async () => { + const nft_id: number = 0; + + await test_ctx.seller.createERC721Sale( + test_ctx.mock_erc721.address, + nft_id, + test_ctx.mock_erc20.address, + 1 + ); + + await expect(test_ctx.seller.withdrawERC721( + test_ctx.mock_erc721.address, + nft_id + )) + .to.be.revertedWithCustomError(test_ctx.seller, "WithdrawError") + .withArgs( + test_ctx.mock_erc721.address, + nft_id + ); + }); + + it("should revert if withdrawing a ERC1155 token with a higher amount than the created one", async () => { + const withdrawable_amount: number = 2; + const nft_amount: number = constants.ERC1155_TOKEN_AMOUNT - withdrawable_amount; + const nft_id: number = 0; + const owner_address: string = await test_ctx.accounts.owner.getAddress(); + + await test_ctx.seller.createERC1155Sale( + test_ctx.mock_erc1155.address, + nft_id, + nft_amount, + test_ctx.mock_erc20.address, + 1 + ); + + // More than withdrawable amount + await expect(test_ctx.seller.withdrawERC1155( + test_ctx.mock_erc1155.address, + nft_id, + withdrawable_amount + 1 + )) + .to.be.revertedWithCustomError(test_ctx.seller, "WithdrawError") + .withArgs( + test_ctx.mock_erc1155.address, + nft_id + ); + + // Equal to withdrawable amount + await expect(test_ctx.seller.withdrawERC1155( + test_ctx.mock_erc1155.address, + nft_id, + withdrawable_amount + )) + .not.to.be.reverted; + expect(await test_ctx.mock_erc1155.balanceOf(owner_address, nft_id)) + .to.equal(withdrawable_amount); + }); +}); diff --git a/test/seller/UtilsSeller.ts b/test/seller/UtilsSeller.ts new file mode 100644 index 0000000..e2849f5 --- /dev/null +++ b/test/seller/UtilsSeller.ts @@ -0,0 +1,129 @@ +import { BigNumber, Contract, ContractFactory } from "ethers"; +import hre from "hardhat"; +// Project +import * as constants from "../common/Constants"; +import { + Accounts, TestContext, + initTestContext, initTokens, deployProxyContract +} from "../common/UtilsCommon"; + + +// +// Interfaces +// + +export interface Sale { + nftAmount: BigNumber; + erc20Contract: string; + erc20Amount: BigNumber; + isActive: boolean; +} + +export interface SellerTestContext extends TestContext { + seller: Contract; +} + +// +// Exported functions +// + +export async function initSellerTestContext() : Promise { + const test_ctx: TestContext = await initTestContext(); + const seller: Contract = await deploySellerProxyContract(test_ctx.payment_erc20_address); + + return { + accounts: test_ctx.accounts, + mock_erc20: test_ctx.mock_erc20, + mock_erc721: test_ctx.mock_erc721, + mock_erc1155: test_ctx.mock_erc1155, + payment_erc20_address: test_ctx.payment_erc20_address, + seller: seller, + user_1_account: test_ctx.user_1_account, + user_1_address: test_ctx.user_1_address, + user_2_account: test_ctx.user_2_account, + user_2_address: test_ctx.user_2_address, + user_3_account: test_ctx.user_3_account, + user_3_address: test_ctx.user_3_address + }; +} + +export async function initSellerTestContextAndToken() : Promise { + const test_ctx: SellerTestContext = await initSellerTestContext(); + await initTokens( + test_ctx.accounts, + test_ctx.seller.address, + test_ctx.mock_erc20, + test_ctx.mock_erc721, + test_ctx.mock_erc1155, + test_ctx.user_1_account, + test_ctx.user_2_account, + test_ctx.user_3_account + ); + + return test_ctx; +} + +export async function initSellerTestContextAndCreate() : Promise { + const test_ctx: SellerTestContext = await initSellerTestContextAndToken(); + const erc20_amount: number = constants.ERC20_TOKEN_SUPPLY / 10; + const nft_id: number = 0; + + await test_ctx.seller.createERC721Sale( + test_ctx.mock_erc721.address, + nft_id, + test_ctx.mock_erc20.address, + erc20_amount + ); + await test_ctx.seller.createERC1155Sale( + test_ctx.mock_erc1155.address, + nft_id, + constants.ERC1155_TOKEN_AMOUNT, + test_ctx.mock_erc20.address, + erc20_amount + ); + + return test_ctx; +} + +export async function deploySellerContract() : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsSeller"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +export async function deploySellerUpgradedContract() : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsSellerUpgraded"); + const instance: Contract = await contract_factory.deploy(); + await instance.deployed(); + + return instance; +} + +export async function getSellerUpgradedContractAt( + address: string +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsSellerUpgraded"); + return contract_factory.attach(address); +} + +// +// Not exported functions +// + +async function getSellerContractAt( + address: string +) : Promise { + const contract_factory: ContractFactory = await hre.ethers.getContractFactory("NftsSeller"); + return contract_factory.attach(address); +} + +async function deploySellerProxyContract( + paymentERC20Address: string +) : Promise { + const seller_logic_instance: Contract = await deploySellerContract(); + const proxy_instance: Contract = await deployProxyContract(seller_logic_instance, paymentERC20Address); + + return getSellerContractAt(proxy_instance.address); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..574e785 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + } +}