From 93b319ba9ba6588ac9199ec28f0d31f80a93d349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Ph=E1=BA=A1m?= Date: Wed, 7 Feb 2024 16:28:26 +0700 Subject: [PATCH] FeralfileArtworkV5 contract to support ERC1155 --- contracts/Authorizable.sol | 2 +- contracts/FeralfileArtworkV5.sol | 658 ++++++++++++++++ contracts/IFeralfileSaleData.sol | 1 + migrations/320_feralfile_exhibition_v5_0.js | 44 ++ test/feralfile_exhibition_v5.js | 830 ++++++++++++++++++++ 5 files changed, 1534 insertions(+), 1 deletion(-) create mode 100644 contracts/FeralfileArtworkV5.sol create mode 100644 migrations/320_feralfile_exhibition_v5_0.js create mode 100644 test/feralfile_exhibition_v5.js diff --git a/contracts/Authorizable.sol b/contracts/Authorizable.sol index 32e1cad..3040020 100644 --- a/contracts/Authorizable.sol +++ b/contracts/Authorizable.sol @@ -9,7 +9,7 @@ contract Authorizable is Ownable { constructor() {} modifier onlyAuthorized() { - require(trustees[msg.sender] || msg.sender == owner()); + require(trustees[_msgSender()] ||_msgSender() == owner()); _; } diff --git a/contracts/FeralfileArtworkV5.sol b/contracts/FeralfileArtworkV5.sol new file mode 100644 index 0000000..9a4a76c --- /dev/null +++ b/contracts/FeralfileArtworkV5.sol @@ -0,0 +1,658 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; + +import "./Authorizable.sol"; +import "./UpdateableOperatorFilterer.sol"; +import "./FeralfileSaleData.sol"; +import "./ECDSASigner.sol"; + +import "./IFeralfileVault.sol"; + +contract FeralfileExhibitionV5 is + ERC1155, + Authorizable, + FeralfileSaleData, + ECDSASigner +{ + using Strings for uint256; + + struct Artwork { + uint256 seriesId; + uint256 tokenId; + uint256 amount; + } + + struct MintData { + uint256 seriesId; + uint256 tokenId; + address owner; + uint256 amount; + } + + struct BurnData { + address from; + uint256 tokenId; + uint256 amount; + } + + // version code of contract + string public constant codeVersion = "FeralfileExhibitionV5"; + + // contract URI + string public contractURI; + + // burnable + bool public burnable; + + // selling + bool public selling; + + // mintable + bool public mintable = true; + + // cost receiver + address public costReceiver; + + // vault contract instance + IFeralfileVault public vault; + + // mapping from series ID to max supply of the series + mapping(uint256 => uint256) internal _seriesMaxSupplies; + + // mapping from series ID to max supply of artwork belongs to the series + mapping(uint256 => uint256) internal _seriesArtworkMaxSupplies; + + // mapping from series ID to total supply of the series + mapping(uint256 => uint256) internal _seriesTotalSupplies; + + // mapping from token ID representing the artwork to total supply of the artwork + mapping(uint256 => uint256) internal _artworkTotalSupplies; + + // mapping from token ID to Artwork + mapping(uint256 => Artwork) internal _allArtworks; + + // mapping from owner to an array of token ID + mapping(address => uint256[]) internal _ownedTokens; + + // mapping from token ID to index of the owner token list + mapping(uint256 => uint256) private _ownedTokensIndex; + + constructor( + string memory baseTokenURI_, + string memory contractURI_, + address signer_, + address vault_, + address costReceiver_, + bool burnable_, + uint256[] memory seriesIds_, + uint256[] memory seriesMaxSupplies_, + uint256[] memory seriesArtworkMaxSupplies_ + ) ERC1155(baseTokenURI_) ECDSASigner(signer_) { + require( + bytes(baseTokenURI_).length > 0, + "FeralfileExhibitionV5: baseTokenURI_ is empty" + ); + require( + bytes(contractURI_).length > 0, + "FeralfileExhibitionV5: contractURI_ is empty" + ); + require( + vault_ != address(0), + "FeralfileExhibitionV5: vault_ is zero address" + ); + require( + costReceiver_ != address(0), + "FeralfileExhibitionV5: costReceiver_ is zero address" + ); + require( + seriesIds_.length > 0, + "FeralfileExhibitionV5: seriesIds_ is empty" + ); + require( + seriesMaxSupplies_.length > 0, + "FeralfileExhibitionV5: _seriesMaxSupplies is empty" + ); + require( + seriesArtworkMaxSupplies_.length > 0, + "FeralfileExhibitionV5: seriesArtworkMaxSupplies_ is empty"); + require( + seriesIds_.length == seriesMaxSupplies_.length && seriesIds_.length == seriesArtworkMaxSupplies_.length, + "FeralfileExhibitionV5: seriesMaxSupplies_ and seriesIds_ lengths are not the same" + ); + + contractURI = contractURI_; + costReceiver = costReceiver_; + vault = IFeralfileVault(payable(vault_)); + burnable = burnable_; + + // initialize max supply map + for (uint256 i = 0; i < seriesIds_.length; i++) { + // Check invalid series ID + require( + seriesIds_[i] > 0, + "FeralfileExhibitionV5: zero seriesId" + ); + // Check invalid max supply + require( + seriesMaxSupplies_[i] > 0, + "FeralfileExhibitionV5: zero series max supply" + ); + require( + seriesArtworkMaxSupplies_[i] > 0, + "FeralfileExhibitionV5: zero artwork max supply" + ); + + // Check duplicate with others + require(_seriesMaxSupplies[seriesIds_[i]] == 0, "FeralfileExhibitionV5: duplicate seriesId"); + require(_seriesArtworkMaxSupplies[seriesIds_[i]] == 0, "FeralfileExhibitionV5: duplicate seriesId"); + + _seriesMaxSupplies[seriesIds_[i]] = seriesMaxSupplies_[i]; + _seriesArtworkMaxSupplies[seriesIds_[i]] = seriesArtworkMaxSupplies_[i]; + } + } + + /// @notice Get all Artworks of an owner + /// @param owner an address of the owner + function artworkOf( + address owner + ) external view returns (Artwork[] memory) { + uint256[] memory tokens = _ownedTokens[owner]; + Artwork[] memory artworks = new Artwork[](tokens.length); + for (uint256 i = 0; i < tokens.length; i++) { + artworks[i] = _allArtworks[tokens[i]]; + } + return artworks; + } + + /// @notice Get all token IDs of an owner + /// @param owner an address of the owner + function tokenOf( + address owner + ) external view returns (uint256[] memory) { + return _ownedTokens[owner]; + } + + /// @notice Get series max supply + /// @param seriesId a series ID + /// @return uint256 the max supply + function seriesMaxSupply( + uint256 seriesId + ) external view virtual returns (uint256) { + return _seriesMaxSupplies[seriesId]; + } + + /// @notice Get series artwork max supply + /// @param seriesId a series ID + function seriesArtworkMaxSupply( + uint256 seriesId + ) external view virtual returns (uint256) { + return _seriesArtworkMaxSupplies[seriesId]; + } + + /// @notice Get series total supply + /// @param seriesId a series ID + /// @return uint256 the total supply + function seriesTotalSupply( + uint256 seriesId + ) external view virtual returns (uint256) { + return _seriesTotalSupplies[seriesId]; + } + + /// @notice Get artwork total supply + /// @param tokenId a token ID representing the artwork + /// @return uint256 the total supply + function artworkTotalSupply( + uint256 tokenId + ) external view virtual returns (uint256) { + return _artworkTotalSupplies[tokenId]; + } + + /// @notice Get artwork data + /// @param tokenId a token ID representing the artwork + /// @return Artwork the Artwork object + function getArtwork( + uint256 tokenId + ) external view virtual returns (Artwork memory) { + return _allArtworks[tokenId]; + } + + /// @notice Set vault contract + /// @param vault_ - the address of vault contract + /// @dev don't allow to set vault as zero address + function setVault(address vault_) external onlyOwner { + require( + vault_ != address(0), + "FeralfileExhibitionV5: vault_ is zero address" + ); + vault = IFeralfileVault(payable(vault_)); + } + + /// @notice set contract URI + /// @param contractURI_ contract URI + function setContractURI(string memory contractURI_) external onlyOwner { + contractURI = contractURI_; + emit ContractURIUpdated(); + } + + /// @notice set base token URI + /// @param uri_ token URI + function setBaseTokenURI(string memory uri_) external onlyOwner { + require( + bytes(uri_).length > 0, + "FeralfileExhibitionV5: uri_ is empty" + ); + _setURI(uri_); + } + + /// @notice the cost receiver address + /// @param costReceiver_ - the address of cost receiver + function setCostReceiver(address costReceiver_) external onlyOwner { + require( + costReceiver_ != address(0), + "FeralfileExhibitionV5: costReceiver_ is zero address" + ); + costReceiver = costReceiver_; + } + + /// @notice Start artwork sale + function startSale() external onlyOwner { + mintable = false; + resumeSale(); + } + + /// @notice Resume artwork sale + function resumeSale() public onlyOwner { + require( + !mintable, + "FeralfileExhibitionV5: mintable required to be false" + ); + require( + !selling, + "FeralfileExhibitionV5: selling required to be false" + ); + require(_ownedTokens[address(this)].length > 0, "FeralfileExhibitionV5: no more artwork to sell"); + + selling = true; + } + + /// @notice Pause artwork sale + function pauseSale() public onlyOwner { + require( + !mintable, + "FeralfileExhibitionV5: mintable required to be false" + ); + require( + selling, + "FeralfileExhibitionV5: selling required to be true" + ); + selling = false; + } + + /// @notice burn unsold artworks + /// @param tokenIds an array of token IDs + function burnUnsoldArtworks(uint256[] calldata tokenIds) external onlyOwner { + require( + !mintable, + "FeralfileExhibitionV5: mintable required to be false" + ); + require( + !selling, + "FeralfileExhibitionV5: selling required to be false" + ); + + uint256[] memory amounts = new uint256[](tokenIds.length); + for (uint256 i = 0; i < tokenIds.length; i++) { + uint256 amount = balanceOf(address(this), tokenIds[i]); + amounts[i] = amount; + } + + _burnBatch(address(this), tokenIds, amounts); + for (uint256 i = 0; i < tokenIds.length; i++) { + delete _allArtworks[tokenIds[i]]; + } + } + + /// @notice transfer unsold artworks to a destination address + /// @param tokenIds an array of token IDs + /// @param to the destination address + function transferUnsoldArtworks(uint256[] calldata tokenIds, address to) external onlyOwner { + require( + !mintable, + "FeralfileExhibitionV5: mintable required to be false" + ); + require( + !selling, + "FeralfileExhibitionV5: selling required to be false" + ); + + uint256[] memory amounts = new uint256[](tokenIds.length); + for (uint256 i = 0; i < tokenIds.length; i++) { + uint256 amount = balanceOf(address(this), tokenIds[i]); + amounts[i] = amount; + } + + _safeBatchTransferFrom(address(this), to, tokenIds, amounts, ""); + } + + /// @notice pay to get artworks to a destination address. The pricing, costs and other details is included in the saleData + /// @param r_ - part of signature for validating parameters integrity + /// @param s_ - part of signature for validating parameters integrity + /// @param v_ - part of signature for validating parameters integrity + /// @param saleData_ - the sale data + function buyArtworks( + bytes32 r_, + bytes32 s_, + uint8 v_, + SaleData calldata saleData_ + ) external payable { + // validation + require(selling, "FeralfileExhibitionV5: sale is not started"); + require(_ownedTokens[address(this)].length > 0, "FeralfileExhibitionV5: no artwork to sell"); + validateSaleData(saleData_); + + // payment + saleData_.payByVaultContract + ? vault.payForSale(r_, s_, v_, saleData_) + : require( + saleData_.price == msg.value, + "FeralfileExhibitionV5: invalid payment amount" + ); + + // validate signature + bytes32 message = keccak256( + abi.encode(block.chainid, address(this), saleData_) + ); + require( + isValidSignature(message, r_, s_, v_), + "FeralfileExhibitionV5: invalid signature" + ); + + // item royalty + uint256 itemRevenue; + if (saleData_.price > saleData_.cost) { + itemRevenue = + (saleData_.price - saleData_.cost) / + saleData_.tokenIds.length; + } + + uint256 distributedRevenue; + uint256 platformRevenue; + for (uint256 i = 0; i < saleData_.tokenIds.length; i++) { + require(saleData_.tokenIds[i] != 0, "FeralfileExhibitionV5: token ID is zero"); + + // send token + _safeTransferFrom( + address(this), + saleData_.destination, + saleData_.tokenIds[i], + 1, + ""); // only support 1 token per transaction + + if (itemRevenue > 0) { + // distribute royalty + for ( + uint256 j = 0; + j < saleData_.revenueShares[i].length; + j++ + ) { + uint256 rev = (itemRevenue * + saleData_.revenueShares[i][j].bps) / 10000; + if ( + saleData_.revenueShares[i][j].recipient == costReceiver + ) { + platformRevenue += rev; + continue; + } + distributedRevenue += rev; + payable(saleData_.revenueShares[i][j].recipient).transfer( + rev + ); + } + } + + emit BuyArtwork(saleData_.destination, saleData_.tokenIds[i]); + } + + require( + saleData_.price - saleData_.cost >= + distributedRevenue + platformRevenue, + "FeralfileExhibitionV5: total bps over 10,000" + ); + + // Transfer cost, platform revenue and remaining funds + uint256 leftOver = saleData_.price - distributedRevenue; + if (leftOver > 0) { + payable(costReceiver).transfer(leftOver); + } + } + + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual override { + super._beforeTokenTransfer(operator, from, to, ids, amounts, data); + + for (uint256 i = 0; i < ids.length; i++) { + uint256 tokenId = ids[i]; + uint256 amount = amounts[i]; + Artwork memory artwork = _allArtworks[tokenId]; + + if (from != address(0) && from != to) { + // only remove Artwork from Artwork enumeration if the current owned + // token amount is equal to the amount of token to be transferred + // otherwise, just update the amount of the token + if (artwork.amount == amount) { + _removeTokenFromOwnerEnumeration(from, ids[i]); + } + } + if (to != address(0) && to != from) { + // only add Artwork to Artwork enumeration if the token is not owned by the receiver + // otherwise, just update the amount of the token + if (_ownedTokensIndex[tokenId] == 0) { + _addTokenToOwnerEnumeration(to, tokenId); + } + } + } + } + + /// @dev Modify from {ERC721Enumerable} + function _removeTokenFromOwnerEnumeration( + address from, + uint256 tokenId + ) private { + // To prevent a gap in from's tokens array, we store the last token in the index of the token to delete, and + // then delete the last slot (swap and pop). + uint256 lastTokenIndex = _ownedTokens[from].length - 1; + uint256 tokenIndex = _ownedTokensIndex[tokenId]; + + // When the token to delete is the last token, the swap operation is unnecessary + if (tokenIndex != lastTokenIndex) { + uint256 lastTokenId = _ownedTokens[from][lastTokenIndex]; + + _ownedTokens[from][tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token + _ownedTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index + } + + delete _ownedTokensIndex[tokenId]; + _ownedTokens[from].pop(); + } + + /// @dev Modify from {ERC721Enumerable} + function _addTokenToOwnerEnumeration(address to, uint256 tokenId) private { + uint256[] storage tokens = _ownedTokens[to]; + uint256 length = tokens.length; + tokens.push(tokenId); + _ownedTokensIndex[tokenId] = length; + } + + /// @notice Mint new collection of Artwork + /// @dev the function iterates over the array of MintData to call the internal function _mintArtwork + /// @param data an array of MintData + function mintArtworks( + MintData[] calldata data + ) external virtual onlyAuthorized { + require( + mintable, + "FeralfileExhibitionV5: contract doesn't allow to mint" + ); + for (uint256 i = 0; i < data.length; i++) { + _mintArtwork(data[i].seriesId, data[i].tokenId, data[i].owner, data[i].amount); + } + } + + function _mintArtwork( + uint256 seriesId, + uint256 tokenId, + address owner, + uint256 amount + ) internal { + require(tokenId != 0, "FeralfileExhibitionV5: token ID is zero"); + require(amount != 0, "FeralfileExhibitionV5: amount is zero"); + + Artwork memory artwork = _allArtworks[tokenId]; + if (artwork.tokenId != 0) { + // reassign seriesId for existing artwork to avoid bypass the max supply checks + seriesId = artwork.seriesId; + } + + // check series exists + require( + _seriesMaxSupplies[seriesId] > 0, + string( + abi.encodePacked( + "FeralfileExhibitionV5: seriesId doesn't exist: ", + Strings.toString(seriesId) + ) + ) + ); + + // check artwork total supplies + require( + _artworkTotalSupplies[tokenId] + amount <= _seriesArtworkMaxSupplies[seriesId], + "FeralfileExhibitionV5: no more artwork slots available" + ); + + if (artwork.tokenId != 0) { + // if artwork exists before + // 1.check series total supplies + require( + _seriesTotalSupplies[seriesId] < _seriesMaxSupplies[seriesId], + "FeralfileExhibitionV5: no more series slots available" + ); + + // 2. increase artwork amount + _allArtworks[tokenId].amount += amount; + } else { + // if artwork doesn't exist before + // 1. increase series total supplies + _seriesTotalSupplies[seriesId] += 1; + + // 2. add artwork to allArtworks + _allArtworks[tokenId] = Artwork({ + seriesId: seriesId, + tokenId: tokenId, + amount: amount}); + } + + // increase artwork total supplies + _artworkTotalSupplies[tokenId] += amount; + + // mint token + _mint(owner, tokenId, amount, ""); + + // emit event + emit NewArtwork(owner, seriesId, tokenId, amount); + } + + /// @notice Burn a collection of artworks + /// @dev the function iterates over the array of token ID to call the internal function _burnArtwork + /// @param data an array of BurnData + function burnArtworks(BurnData[] calldata data) external { + require(burnable, "FeralfileExhibitionV5: token is not burnable"); + for (uint256 i = 0; i < data.length; i++) { + _burnArtwork(data[i].from, data[i].tokenId, data[i].amount); + } + } + + function _burnArtwork(address from, uint256 tokenId, uint256 amount) internal { + require(tokenId != 0, "FeralfileExhibitionV5: token ID is zero"); + require(amount != 0, "FeralfileExhibitionV5: amount is zero"); + require(from != address(0), "FeralfileExhibitionV5: from is zero address"); + + Artwork memory artwork = _allArtworks[tokenId]; + require(artwork.tokenId != 0, "FeralfileExhibitionV5: artwork doesn't exist"); + + // burn artwork + _burn(from, tokenId, amount); + + if (artwork.amount == amount) { + // if burn whole token of artwork + // 1. decrease series total supplies + // 2. remove artwork from allArtworks + _seriesTotalSupplies[artwork.seriesId] -= 1; + delete _allArtworks[tokenId]; + } else { + // if burn part of token of artwork + // just decrease artwork amount + _allArtworks[tokenId].amount -= amount; + } + + // decrease artwork total supplies + _artworkTotalSupplies[tokenId] -= amount; + + // emit event + emit BurnArtwork(tokenId, amount); + } + + /// @notice able to receive fund from vault contract + receive() external payable { + require( + msg.sender == address(vault), + "FeralfileExhibitionV5: only accept fund from vault contract." + ); + } + + function onERC1155Received( + address, + address from_, + uint256, + uint256, + bytes memory + ) public pure returns (bytes4) { + require( + from_ == address(0), + "FeralFileAirdropV1: not allowed to send token back" + ); + return this.onERC1155Received.selector; + } + + /// @notice check if artwork exists + function _artworkExists(uint256 tokenId) internal view returns (bool) { + require(tokenId != 0, "FeralfileExhibitionV5: token ID is zero"); + return _allArtworks[tokenId].tokenId == tokenId; + } + + /// @notice Event emitted when new Artwork has been minted + event NewArtwork( + address indexed owner, + uint256 indexed seriesId, + uint256 indexed tokenId, + uint256 amount + ); + + /// @notice Event emitted when Artwork has been burned + event BurnArtwork(uint256 indexed tokenId, uint256 amount); + + /// @notice Event emitted when Artwork has been sold + event BuyArtwork(address indexed buyer, uint256 indexed tokenId); + + /// @notice Event emitted when contract URI has been updated + event ContractURIUpdated(); +} diff --git a/contracts/IFeralfileSaleData.sol b/contracts/IFeralfileSaleData.sol index add4f02..65a0977 100644 --- a/contracts/IFeralfileSaleData.sol +++ b/contracts/IFeralfileSaleData.sol @@ -15,5 +15,6 @@ interface IFeralfileSaleData { uint256[] tokenIds; RevenueShare[][] revenueShares; // address and royalty bps (500 means 5%) bool payByVaultContract; // get eth from vault contract, used by credit card pay that proxy by ITX + uint256 biddingUnixNano; // in nano } } diff --git a/migrations/320_feralfile_exhibition_v5_0.js b/migrations/320_feralfile_exhibition_v5_0.js new file mode 100644 index 0000000..37912b7 --- /dev/null +++ b/migrations/320_feralfile_exhibition_v5_0.js @@ -0,0 +1,44 @@ +var FeralfileExhibitionV5 = artifacts.require("FeralfileExhibitionV5"); + +const argv = require("minimist")(process.argv.slice(2), { + string: [ + "exhibition_signer", + "exhibition_vault", + "exhibition_cost_receiver", + ], +}); + +module.exports = function (deployer) { + let exhibition_signer = + argv.exhibition_signer || "0x6732389c6d47d01487dcDc96e2Cc6BAf108452f2"; + let exhibition_vault = + argv.exhibition_vault || "0x0c51e8becb17ba3203cd04d3fc31fcb90de412a1"; + let exhibition_cost_receiver = + argv.exhibition_cost_receiver || + "0x6732389c6d47d01487dcDc96e2Cc6BAf108452f2"; + let burnable = argv.burnable || true; + let contract_uri = + argv.contract_uri || + "ipfs://QmaQegRqExfFx8zuR6yscxzUwQJUc96LuNNQiAMK9BsUQe"; + let token_uri = + argv.token_uri || + "ipfs://QmZt5r6bU7r3BvCp6b6Z1yYK5Hd9M9WnQV2g8VfNnMmM3T/{id}"; + let series_ids = argv.series_ids || [1, 2, 3, 4]; + let series_max_supplies = argv.series_max_supplies || [10, 10, 10, 1]; + let series_artwork_max_supplies = argv.series_artwork_max_supplies || [ + 1, 1, 1, 100, + ]; + + deployer.deploy( + FeralfileExhibitionV5, + token_uri, + contract_uri, + exhibition_signer, + exhibition_vault, + exhibition_cost_receiver, + burnable, + series_ids, + series_max_supplies, + series_artwork_max_supplies + ); +}; diff --git a/test/feralfile_exhibition_v5.js b/test/feralfile_exhibition_v5.js new file mode 100644 index 0000000..4eac5a3 --- /dev/null +++ b/test/feralfile_exhibition_v5.js @@ -0,0 +1,830 @@ +const FeralfileExhibitionV5 = artifacts.require("FeralfileExhibitionV5"); +const FeralfileVault = artifacts.require("FeralfileVault"); + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; +const COST_RECEIVER = "0x46f2B641d8702f29c45f6D06292dC34Eb9dB1801"; +const VAULT_ADDRESS = "0x7a15b36cb834aea88553de69077d3777460d73ac"; +const CONTRACT_URI = "ipfs://QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc"; +const TOKEN_URI = "ipfs://QmZt5r6bU7r3BvCp6b6Z1yYK5Hd9M9WnQV2g8VfNnMmM3T/{id}"; + +contract("FeralfileExhibitionV5_0", async (accounts) => { + before(async function () { + this.owner = accounts[0]; + this.trustee = accounts[1]; + this.signer = accounts[2]; + this.vault = await FeralfileVault.new(this.signer); + this.seriesIds = [1, 2, 3, 4, 5]; + this.seriesMaxSupply = [1, 5, 10, 1, 1]; + this.seriesArtworkMaxSupply = [1, 1, 1, 100, 100]; + this.contracts = []; + + // Deploy multiple contracts + for (let i = 0; i < 8; i++) { + let contract = await FeralfileExhibitionV5.new( + TOKEN_URI, + CONTRACT_URI, + this.signer, + this.vault.address, + COST_RECEIVER, + true, + this.seriesIds, + this.seriesMaxSupply, + this.seriesArtworkMaxSupply + ); + await contract.addTrustee(this.trustee); + this.contracts.push(contract); + } + }); + + it("test contract constructor", async function () { + for (let i = 0; i < this.contracts.length; i++) { + let contract = this.contracts[i]; + + // burnable + const burnable = await contract.burnable(); + assert.ok(burnable); + + // mintable + const mintable = await contract.mintable(); + assert.ok(mintable); + + // selling + const selling = await contract.selling(); + assert.ok(!selling); + + // vault + const vaultAddress = await contract.vault(); + assert.equal(this.vault.address, vaultAddress); + + // signer + const signer = await contract.signer(); + assert.equal(this.signer, signer); + + // series max supply + const seriesMaxSupply1 = await contract.seriesMaxSupply( + this.seriesIds[0] + ); + assert.equal(seriesMaxSupply1, this.seriesMaxSupply[0]); + + const seriesMaxSupply2 = await contract.seriesMaxSupply( + this.seriesIds[1] + ); + assert.equal(seriesMaxSupply2, this.seriesMaxSupply[1]); + + // series total supply + const seriesTotalSupply = await contract.seriesTotalSupply( + this.seriesIds[0] + ); + assert.equal(seriesTotalSupply, 0); + + // artwork total supply + const artworkTotalSupply = await contract.artworkTotalSupply( + this.seriesIds[4] + ); + assert.equal(artworkTotalSupply, 0); + + // series artwork max supply + const seriesArtworkMaxSupply = + await contract.seriesArtworkMaxSupply(this.seriesIds[4]); + assert.equal(seriesArtworkMaxSupply, 100); + } + }); + + it("test failed constructor", async function () { + const data = [ + [ + "", + CONTRACT_URI, + this.signer, + this.vault.address, + COST_RECEIVER, + true, + [1, 2, 3], + [1, 1, 1], + [1, 1, 1], + "FeralfileExhibitionV5: baseTokenURI_ is empty", + ], // empty base token URI + [ + TOKEN_URI, + "", + this.signer, + this.vault.address, + COST_RECEIVER, + true, + [1, 2, 3], + [1, 1, 1], + [1, 1, 1], + "FeralfileExhibitionV5: contractURI_ is empty", + ], // empty contract URI + [ + TOKEN_URI, + CONTRACT_URI, + ZERO_ADDRESS, + this.vault.address, + COST_RECEIVER, + true, + [1, 2, 3], + [1, 1, 1], + [1, 1, 1], + "ECDSASign: signer_ is zero address", + ], // zero signer + [ + TOKEN_URI, + CONTRACT_URI, + this.signer, + ZERO_ADDRESS, + COST_RECEIVER, + true, + [1, 2, 3], + [1, 1, 1], + [1, 1, 1], + "FeralfileExhibitionV5: vault_ is zero address", + ], // zero vault address + [ + TOKEN_URI, + CONTRACT_URI, + this.signer, + this.vault.address, + ZERO_ADDRESS, + true, + [1, 2, 3], + [1, 1, 1], + [1, 1, 1], + "FeralfileExhibitionV5: costReceiver_ is zero address", + ], // zero cost receiver + [ + TOKEN_URI, + CONTRACT_URI, + this.signer, + this.vault.address, + COST_RECEIVER, + true, + [0, 1, 2], + [1, 1, 1], + [1, 1, 1], + "FeralfileExhibitionV5: zero seriesId", + ], // zero series ID + [ + TOKEN_URI, + CONTRACT_URI, + this.signer, + this.vault.address, + COST_RECEIVER, + true, + [1, 2, 3], + [1, 0, 1], + [1, 1, 1], + "FeralfileExhibitionV5: zero series max supply", + ], // zero series max supply + [ + TOKEN_URI, + CONTRACT_URI, + this.signer, + this.vault.address, + COST_RECEIVER, + true, + [1, 2, 3], + [1, 1, 1], + [0, 1, 1], + "FeralfileExhibitionV5: zero artwork max supply", + ], // zero artwork max supply + [ + TOKEN_URI, + CONTRACT_URI, + this.signer, + this.vault.address, + COST_RECEIVER, + true, + [1, 2, 1], + [1, 1, 1], + [1, 1, 1], + "FeralfileExhibitionV5: duplicate seriesId", + ], // duplicate series ID + ]; + + for (let i = 0; i < data.length; i++) { + try { + await FeralfileExhibitionV5.new( + data[i][0], + data[i][1], + data[i][2], + data[i][3], + data[i][4], + data[i][5], + data[i][6], + data[i][7], + data[i][8] + ); + } catch (error) { + assert.equal(data[i][9], error.reason); + } + } + }); + + it("test mint artwork", async function () { + const contract = this.contracts[0]; + const owner = accounts[1]; + const seriesId = this.seriesIds[0]; + + // 1. unauthorized call + try { + await contract.mintArtworks([[seriesId, 1, owner, 1]], { + from: accounts[2], + }); + } catch (error) { + assert.equal( + "VM Exception while processing transaction: revert", + error.message + ); + } + + // 2. pre-condition check failed + const data = [ + [ + [0, 1, owner, 1], + "FeralfileExhibitionV5: seriesId doesn't exist: 0", + ], // series ID doesn't exist + [ + [seriesId, 0, owner, 1], + "FeralfileExhibitionV5: token ID is zero", + ], // token ID is zero + [ + [seriesId, 1, ZERO_ADDRESS, 1], + "ERC1155: mint to the zero address", + ], // mint to zero address + [[seriesId, 1, owner, 0], "FeralfileExhibitionV5: amount is zero"], // amount is zero + [ + [seriesId, 1, owner, 2], + "FeralfileExhibitionV5: no more artwork slots available", + ], // exceed artwork max supply + ]; + for (let i = 0; i < data.length; i++) { + try { + await contract.mintArtworks([data[i][0]]); + } catch (error) { + assert.equal(data[i][1], error.reason); + } + } + + const testData = [ + [this.seriesIds[0], 1, accounts[1], 1], // unique artwork + [this.seriesIds[4], 2, accounts[2], 50], // edition + ]; + + for (let i = 0; i < testData.length; i++) { + const seriesId = testData[i][0]; + const tokenId = testData[i][1]; + const owner = testData[i][2]; + const amount = testData[i][3]; + + // 3. mint successfully + const tx = await contract.mintArtworks([ + [seriesId, tokenId, owner, amount], + ]); + + // check artworkOf() + const artworks = await contract.artworkOf(owner); + assert.equal(artworks.length, 1); + assert.equal(artworks[0].seriesId, seriesId); + assert.equal(artworks[0].tokenId, tokenId); + assert.equal(artworks[0].amount, amount); + + // check seriesTotalSupply() + const seriesTotalSupply = await contract.seriesTotalSupply( + seriesId + ); + assert.equal(seriesTotalSupply, 1); + + // check artworkTotalSupply() + const artworkTotalSupply = await contract.artworkTotalSupply( + tokenId + ); + assert.equal(artworkTotalSupply, amount); + + // check balanceOf() + const balance = await contract.balanceOf(owner, tokenId); + assert.equal(balance, amount); + + // get artwork + const artwork = await contract.getArtwork(tokenId); + assert.equal(artwork.tokenId, tokenId); + assert.equal(artwork.seriesId, seriesId); + assert.equal(artwork.amount, amount); + + // event emitted + const { logs } = tx; + assert.ok(Array.isArray(logs)); + assert.equal(logs.length, 2); + + const log0 = logs[0]; + const log1 = logs[1]; + assert.equal(log0.event, "TransferSingle"); + assert.equal(log1.event, "NewArtwork"); + + // 4. mint failed due to series max supply + try { + await contract.mintArtworks([ + [seriesId, tokenId * 123, owner, 1], + ]); + } catch (error) { + assert.equal( + "FeralfileExhibitionV5: no more series slots available", + error.reason + ); + } + + // 5. mint failed due to artwork max supply + try { + await contract.mintArtworks([ + [seriesId, tokenId, owner, amount * 123], + ]); + } catch (error) { + assert.equal( + "FeralfileExhibitionV5: no more artwork slots available", + error.reason + ); + } + } + }); + + it("test burn artwork", async function () { + const contract = this.contracts[1]; + const owner = accounts[1]; + const seriesId = this.seriesIds[0]; + + // 1. pre-condition check failed + const data = [ + [ + [ZERO_ADDRESS, 1, 1], + "FeralfileExhibitionV5: from is zero address", + ], // from is zero address + [[owner, 0, 1], "FeralfileExhibitionV5: token ID is zero"], // token ID is zero + [[owner, 1, 0], "FeralfileExhibitionV5: amount is zero"], // amount is zero + [[owner, 1, 1], "FeralfileExhibitionV5: artwork doesn't exist"], // exceed artwork max supply + ]; + for (let i = 0; i < data.length; i++) { + try { + await contract.burnArtworks([data[i][0]]); + } catch (error) { + assert.equal(data[i][1], error.reason); + } + } + + // 2. burn successfully + const testData = [ + [this.seriesIds[0], 1, accounts[1], 1], // unique artwork + [this.seriesIds[4], 2, accounts[2], 50], // edition + ]; + for (let i = 0; i < testData.length; i++) { + const seriesId = testData[i][0]; + const tokenId = testData[i][1]; + const owner = testData[i][2]; + const amount = testData[i][3]; + const unique = amount == 1; + + // mint artworks + await contract.mintArtworks([[seriesId, tokenId, owner, amount]]); + + // check artworkOf() + let artworks = await contract.artworkOf(owner); + assert.equal(artworks.length, 1); + assert.equal(artworks[0].seriesId, seriesId); + assert.equal(artworks[0].tokenId, tokenId); + assert.equal(artworks[0].amount, amount); + + // check seriesTotalSupply() + let seriesTotalSupply = await contract.seriesTotalSupply(seriesId); + assert.equal(seriesTotalSupply, 1); + + // check artworkTotalSupply() + let artworkTotalSupply = await contract.artworkTotalSupply(tokenId); + assert.equal(artworkTotalSupply, amount); + + // check balanceOf() + let balance = await contract.balanceOf(owner, tokenId); + assert.equal(balance, amount); + + // get artwork + let artwork = await contract.getArtwork(tokenId); + assert.equal(artwork.tokenId, tokenId); + assert.equal(artwork.seriesId, seriesId); + assert.equal(artwork.amount, amount); + + // burn artwork + let burnAmount = unique ? 1 : amount / 2; // burn 1 for unique artwork, burn half for edition + const tx = await contract.burnArtworks([ + [owner, tokenId, burnAmount], + ]); + + // check artworkOf() + artworks = await contract.artworkOf(owner); + let artworkLen = unique ? 0 : 1; + assert.equal(artworks.length, artworkLen); + + // check seriesTotalSupply() + seriesTotalSupply = await contract.seriesTotalSupply(seriesId); + let sts = unique ? 0 : 1; + assert.equal(seriesTotalSupply, sts); + + // check artworkTotalSupply() + artworkTotalSupply = await contract.artworkTotalSupply(tokenId); + let ats = unique ? 0 : amount - burnAmount; + assert.equal(artworkTotalSupply, ats); + + // check balanceOf() + balance = await contract.balanceOf(owner, tokenId); + let b = unique ? 0 : amount - burnAmount; + assert.equal(balance, b); + + // get artwork + artwork = await contract.getArtwork(tokenId); + let ti = unique ? 0 : tokenId; + assert.equal(artwork.tokenId, ti); + + // event emitted + const { logs } = tx; + assert.ok(Array.isArray(logs)); + assert.equal(logs.length, 2); + + const log0 = logs[0]; + const log1 = logs[1]; + assert.equal(log0.event, "TransferSingle"); + assert.equal(log1.event, "BurnArtwork"); + } + }); + + it("test buy artworks not ok", async function () { + const contract = this.contracts[2]; + const price = 0.25 * 1e18; + const cost = 0.02 * 1e18; + const expiryTime = (new Date().getTime() / 1000 + 300).toFixed(0); + const recipient = accounts[3]; + const buyer = accounts[2]; + const seriesIds = [this.seriesIds[0], this.seriesIds[3]]; + const tokenIds = [1, 2]; + const biddingUnixNano = BigInt(new Date().getTime() * 1e6).toString(); + const royaltyShares = [ + [ + [accounts[4], 8000], + [accounts[5], 2000], + ], + [ + [accounts[4], 8000], + [accounts[5], 2000], + ], + ]; + const chainId = await web3.eth.getChainId(); + const saleData = [ + BigInt(price).toString(), + BigInt(cost).toString(), + expiryTime, + recipient, + tokenIds, + royaltyShares, + false, + biddingUnixNano, + ]; + + // 1. the sale is not started yet + try { + await contract.buyArtworks("0x0", "0x0", 0 + 27, saleData, { + from: buyer, + value: price, + }); + } catch (error) { + assert.equal( + "FeralfileExhibitionV5: sale is not started", + error.reason + ); + } + + // mint artworks and start sale + await contract.mintArtworks([ + [seriesIds[0], tokenIds[0], contract.address, 1], + [seriesIds[1], tokenIds[1], contract.address, 100], + ]); + await contract.startSale(); + + // 2. empty token IDs + saleData[4] = []; + try { + await contract.buyArtworks("0x0", "0x0", 0 + 27, saleData, { + from: buyer, + value: price, + }); + } catch (error) { + assert.equal("FeralfileSaleData: tokenIds is empty", error.reason); + } + saleData[4] = tokenIds; + + // 3. tokenIds and royaltyShares length mismatch + saleData[4] = [1]; + try { + await contract.buyArtworks("0x0", "0x0", 0 + 27, saleData, { + from: buyer, + value: price, + }); + } catch (error) { + assert.equal( + "FeralfileSaleData: tokenIds and revenueShares length mismatch", + error.reason + ); + } + saleData[4] = tokenIds; + + // 4. expired sale + saleData[2] = (new Date().getTime() / 1000 - 300).toFixed(0); + try { + await contract.buyArtworks("0x0", "0x0", 0 + 27, saleData, { + from: buyer, + value: price, + }); + } catch (error) { + assert.equal("FeralfileSaleData: sale is expired", error.reason); + } + saleData[2] = expiryTime; + + // 5. invalid signature + try { + await contract.buyArtworks("0x0", "0x0", 0 + 27, saleData, { + from: buyer, + value: price, + }); + } catch (error) { + assert.equal("ECDSA: invalid signature", error.reason); + } + + // 6. wrong signature + // encode params + let signedParams = web3.eth.abi.encodeParameters( + [ + "uint", + "address", + "tuple(uint256,uint256,uint256,address,uint256[],tuple(address,uint256)[][],bool,uint256)", + ], + [BigInt(chainId).toString(), contract.address, saleData] + ); + + // sign params + let hash = web3.utils.keccak256(signedParams); + let sig = await web3.eth.sign(hash, accounts[0]); + sig = sig.substr(2); + let r = "0x" + sig.slice(0, 64); + let s = "0x" + sig.slice(64, 128); + let v = "0x" + sig.slice(128, 130); + + try { + await contract.buyArtworks( + r, + s, + web3.utils.toDecimal(v) + 27, + saleData, + { + from: buyer, + value: price, + } + ); + } catch (error) { + assert.equal( + "FeralfileExhibitionV5: invalid signature", + error.reason + ); + } + + // 7. over max bps + saleData[5] = [ + [ + [accounts[4], 8000], + [accounts[5], 2001], + ], + [ + [accounts[4], 8000], + [accounts[5], 2000], + ], + ]; + + // encode params + signedParams = web3.eth.abi.encodeParameters( + [ + "uint", + "address", + "tuple(uint256,uint256,uint256,address,uint256[],tuple(address,uint256)[][],bool,uint256)", + ], + [BigInt(chainId).toString(), contract.address, saleData] + ); + + // sign params + hash = web3.utils.keccak256(signedParams); + sig = await web3.eth.sign(hash, this.signer); + sig = sig.substr(2); + r = "0x" + sig.slice(0, 64); + s = "0x" + sig.slice(64, 128); + v = "0x" + sig.slice(128, 130); + try { + await contract.buyArtworks( + r, + s, + web3.utils.toDecimal(v) + 27, + saleData, + { + from: buyer, + value: price, + } + ); + } catch (error) { + assert.equal( + "FeralfileExhibitionV5: total bps over 10,000", + error.reason + ); + } + saleData[5] = royaltyShares; + }); + + it("test buy artworks ok", async function () { + const testData = [ + [ + [1, 2], // token IDs + [this.seriesIds[0], this.seriesIds[3]], // series ID, both unique and edition + [1, 100], // amounts + accounts[2], // buyer + accounts[3], // recipient + [0.8, 0.2], // royalties shares + [accounts[4], accounts[5]], // royalty payees + true, // with funds + this.contracts[3], + ], + [ + [3, 4], // token IDs + [this.seriesIds[1], this.seriesIds[4]], // series ID, both unique and edition + [1, 100], // amounts + accounts[6], // buyer + accounts[7], // recipient + [0.8, 0.2], // royalties shares + [accounts[8], accounts[9]], // royalty payees + false, // without funds + this.contracts[4], + ], + ]; + + for (let i = 0; i < testData.length; i++) { + const tokenIDs = testData[i][0]; + const seriesIds = testData[i][1]; + const amounts = testData[i][2]; + const buyer = testData[i][3]; + const recipient = testData[i][4]; + const royalties = testData[i][5]; + const royaltyPayees = testData[i][6]; + const withFunds = testData[i][7]; + const contract = testData[i][8]; + const owner = contract.address; + const bps = 10000; + const royaltyShares = [ + [ + [royaltyPayees[0], royalties[0] * bps], + [royaltyPayees[1], royalties[1] * bps], + ], + [ + [royaltyPayees[0], royalties[0] * bps], + [royaltyPayees[1], royalties[1] * bps], + ], + ]; + const price = 0.25 * 1e18; + const cost = 0.02 * 1e18; + const chainId = await web3.eth.getChainId(); + const payByVault = !withFunds; + + if (payByVault) { + // deposit for vault contract + await web3.eth.sendTransaction({ + to: this.vault.address, + from: accounts[0], + value: BigInt(price).toString(), + }); + } + + // mint artwork + await contract.mintArtworks( + [ + [seriesIds[0], tokenIDs[0], owner, amounts[0]], + [seriesIds[1], tokenIDs[1], owner, amounts[1]], + ], + { from: this.trustee } + ); + const balanceOfToken1 = await contract.balanceOf( + owner, + tokenIDs[0] + ); + assert.equal(balanceOfToken1, amounts[0]); + const balanceOfToken2 = await contract.balanceOf( + owner, + tokenIDs[1] + ); + assert.equal(balanceOfToken2, amounts[1]); + + // start sale + await contract.startSale(); + + // encode params + const expiryTime = (new Date().getTime() / 1000 + 300).toFixed(0); + const biddingUnixNano = BigInt( + new Date().getTime() * 1e6 + ).toString(); + const saleData = [ + BigInt(price).toString(), + BigInt(cost).toString(), + expiryTime, + recipient, + tokenIDs, + royaltyShares, + payByVault, + biddingUnixNano, + ]; + const signedParams = web3.eth.abi.encodeParameters( + [ + "uint", + "address", + "tuple(uint256,uint256,uint256,address,uint256[],tuple(address,uint256)[][],bool,uint256)", + ], + [BigInt(chainId).toString(), contract.address, saleData] + ); + + // sign params + const hash = web3.utils.keccak256(signedParams); + var sig = await web3.eth.sign(hash, this.signer); + sig = sig.substr(2); + const r = "0x" + sig.slice(0, 64); + const s = "0x" + sig.slice(64, 128); + const v = "0x" + sig.slice(128, 130); + + // check balances for stakeholders + const payee1BalanceBefore = await web3.eth.getBalance( + royaltyPayees[0] + ); + const payee2BalanceBefore = await web3.eth.getBalance( + royaltyPayees[1] + ); + const costReceiverBalanceBefore = await web3.eth.getBalance( + COST_RECEIVER + ); + + // buy artworks + const funds = withFunds ? price : 0; + await contract.buyArtworks( + r, + s, + web3.utils.toDecimal(v) + 27, // magic 27 + saleData, + { from: buyer, value: funds } + ); + + // check recipient token balance + for (let i = 0; i < tokenIDs.length; i++) { + const balanceOf = await contract.balanceOf( + recipient, + tokenIDs[i] + ); + assert.equal(balanceOf, 1); + } + + // check artworkOf() + const artworks = await contract.artworkOf(recipient); + assert.equal(artworks.length, 2); + for (let i = 0; i < artworks.length; i++) { + assert.equal(artworks[i].seriesId, seriesIds[i]); + assert.equal(artworks[i].tokenId, tokenIDs[i]); + assert.equal(artworks[i].amount, amounts[i]); + } + + // check payees's balances + const payee1BalanceAfter = await web3.eth.getBalance( + royaltyPayees[0] + ); + const payee2BalanceAfter = await web3.eth.getBalance( + royaltyPayees[1] + ); + const costReceiverBalanceAfter = await web3.eth.getBalance( + COST_RECEIVER + ); + + assert.equal( + ( + BigInt(payee1BalanceAfter) - BigInt(payee1BalanceBefore) + ).toString(), + BigInt((price - cost) * royalties[0]).toString() + ); + assert.equal( + ( + BigInt(payee2BalanceAfter) - BigInt(payee2BalanceBefore) + ).toString(), + BigInt((price - cost) * royalties[1]).toString() + ); + assert.equal( + ( + BigInt(costReceiverBalanceAfter) - + BigInt(costReceiverBalanceBefore) + ).toString(), + BigInt(cost).toString() + ); + } + }); +});