diff --git a/contracts/FeralfileArtworkV4.sol b/contracts/FeralfileArtworkV4.sol index 0794f04..2f20d2b 100644 --- a/contracts/FeralfileArtworkV4.sol +++ b/contracts/FeralfileArtworkV4.sol @@ -8,6 +8,7 @@ import "@openzeppelin/contracts/utils/Strings.sol"; import "./Authorizable.sol"; import "./UpdateableOperatorFilterer.sol"; +import "./FeralfileVault.sol"; contract FeralfileExhibitionV4 is IERC165, @@ -38,11 +39,14 @@ contract FeralfileExhibitionV4 is // contract URI string private _contractURI; + // vault contract address + address private _vaultAddress; + // default true and set to false when the sale starts - bool private canMint = true; + bool private _canMint = true; // default false and set to true when the sale starts - bool private isSelling = false; + bool private _isSelling = false; struct Artwork { uint256 seriesIndex; @@ -56,13 +60,15 @@ contract FeralfileExhibitionV4 is } struct SaleData { - uint price; // in wei - uint cost; // in wei - uint expiryTime; - Royalty[] royalties; // address: royalty bps (500 means 5%) + uint256 price; // in wei + uint256 cost; // in wei + uint256 expiryTime; + address destination; + uint256[] tokenIds; + Royalty[][] royalties; // address and royalty bps (500 means 5%) + bool payByVaultContract; // get eth from vault contract, used by credit card pay that proxy by ITX } - mapping(string => bool) internal registeredIPFSCIDs; // ipfsCID => bool mapping(uint256 => Artwork) public artworks; // => tokenID => Artwork constructor( @@ -71,6 +77,7 @@ contract FeralfileExhibitionV4 is string memory contractURI_, string memory tokenBaseURI_, address signer_, + address vaultAddress_, bool isBurnable_, bool isBridgeable_ ) ERC721(name_, symbol_) { @@ -79,6 +86,7 @@ contract FeralfileExhibitionV4 is _contractURI = contractURI_; _tokenBaseURI = tokenBaseURI_; signer = signer_; + _vaultAddress = vaultAddress_; } function supportsInterface( @@ -133,15 +141,21 @@ contract FeralfileExhibitionV4 is signer = signer_; } + /// @notice the vault contract address + /// @param vaultAddress_ - the address of vault contract + function setVaultContract(address vaultAddress_) external onlyOwner { + _vaultAddress = vaultAddress_; + } + // @notice to start the sale function startSale() external onlyOwner { - canMint = false; - isSelling = true; + _canMint = false; + _isSelling = true; } // @notice to end the sale function endSale() external onlyOwner { - isSelling = false; + _isSelling = false; } /// @notice isValidRequest validates a message by ecrecover to ensure @@ -190,23 +204,15 @@ contract FeralfileExhibitionV4 is uint256 artworkIndex_, string memory ipfsCID_ ) internal { - require(canMint, "FeralfileExhibitionV4: not in minting stage"); + require(_canMint, "FeralfileExhibitionV4: not in minting stage"); require( - seriesIndex_ >= 0, + seriesIndex_ > 0, "FeralfileExhibitionV4: invalid series index" ); - require( - !registeredIPFSCIDs[ipfsCID_], - "FeralfileExhibitionV4: IPFS cid already registered" - ); - - uint256 artworkID = (seriesIndex_ + 1) * - ARTWORK_ID_MULTIPLE + - artworkIndex_; + uint256 artworkID = seriesIndex_ * ARTWORK_ID_MULTIPLE + artworkIndex_; _mint(address(this), artworkID); artworks[artworkID] = Artwork(seriesIndex_, artworkIndex_, ipfsCID_); - registeredIPFSCIDs[ipfsCID_] = true; emit NewArtwork(address(this), artworkIndex_, artworkID); } @@ -215,57 +221,62 @@ contract FeralfileExhibitionV4 is /// @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 destination_ - the address of receiver - /// @param tokenIds_ - the array of token id /// @param saleData_ - the sale data function buyArtworks( bytes32 r_, bytes32 s_, uint8 v_, - address destination_, - uint256[] memory tokenIds_, SaleData calldata saleData_ ) external payable { - require(isSelling, "FeralfileExhibitionV4: sale is not started"); + require(_isSelling, "FeralfileExhibitionV4: sale is not started"); require( - tokenIds_.length > 0, + saleData_.tokenIds.length > 0, "FeralfileExhibitionV4: tokenIds is empty" ); + require( + saleData_.tokenIds.length == saleData_.royalties.length, + "FeralfileExhibitionV4: tokenIds and royalties length mismatch" + ); require( saleData_.expiryTime > block.timestamp, "FeralfileExhibitionV4: sale is expired" ); require( - saleData_.cost == msg.value, - "FeralfileExhibitionV4: invalid payment amount" + saleData_.price == msg.value, + "FeralfileExhibitionV4: invalid payable amount and price" ); - bytes32 requestHash = keccak256( - abi.encode( - destination_, - tokenIds_, - saleData_.price, - saleData_.cost, - saleData_.expiryTime - ) - ); + if (saleData_.payByVaultContract) { + // pay eth by vault contract + FeralfileVault(payable(_vaultAddress)).pay(saleData_.cost); + } + + bytes32 requestHash = keccak256(abi.encode(saleData_)); require( isValidRequest(requestHash, signer, r_, s_, v_), "FeralfileExhibitionV4: invalid signature" ); - // send NFT - for (uint256 i = 0; i < tokenIds_.length; i++) { - _safeTransfer(address(this), destination_, tokenIds_[i], ""); + uint256 itemCost = saleData_.cost / saleData_.tokenIds.length; - emit BuyArtwork(destination_, tokenIds_[i]); - } - // distribute royalty - for (uint256 i = 0; i < saleData_.royalties.length; i++) { - payable(saleData_.royalties[i].recipient).transfer( - (msg.value * saleData_.royalties[i].bps) / 10000 + for (uint256 i = 0; i < saleData_.tokenIds.length; i++) { + // send NFT + _safeTransfer( + address(this), + saleData_.destination, + saleData_.tokenIds[i], + "" ); + + // distribute royalty + for (uint256 j = 0; j < saleData_.royalties[i].length; j++) { + payable(saleData_.royalties[i][j].recipient).transfer( + (itemCost * saleData_.royalties[i][j].bps) / 10000 + ); + } + + emit BuyArtwork(saleData_.destination, saleData_.tokenIds[i]); } } @@ -282,7 +293,6 @@ contract FeralfileExhibitionV4 is _isApprovedOrOwner(_msgSender(), artworkIDs_[i]), "FeralfileExhibitionV4: caller is not owner nor approved" ); - delete registeredIPFSCIDs[artworks[artworkIDs_[i]].ipfsCID]; delete artworks[artworkIDs_[i]]; _burn(artworkIDs_[i]); @@ -291,6 +301,14 @@ contract FeralfileExhibitionV4 is } } + // @notice able to recieve funds from vault contract + receive() external payable { + require( + msg.sender == _vaultAddress, + "only accept fund from vault contract." + ); + } + event NewArtwork( address indexed owner, uint256 indexed artworkIndex, diff --git a/contracts/FeralfileVault.sol b/contracts/FeralfileVault.sol new file mode 100644 index 0000000..abcc544 --- /dev/null +++ b/contracts/FeralfileVault.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/math/SafeMath.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract FeralfileVault is Ownable { + mapping(address => bool) public exhibitionContract; + + modifier onlyExhibitionContract() { + require( + exhibitionContract[msg.sender], + "Only exhibition contract can call this function." + ); + _; + } + + function setExhibitionContract(address ec_) external onlyOwner { + exhibitionContract[ec_] = true; + } + + function unsetExhibitionContract(address ec_) external onlyOwner { + exhibitionContract[ec_] = false; + } + + function pay(uint256 amount_) external onlyExhibitionContract { + require( + address(this).balance >= amount_, + "insufficient balance" + ); + payable(msg.sender).transfer(amount_); + } + + fallback() external payable {} + + receive() external payable {} +} diff --git a/test/feralfile_exhibition_v4.js b/test/feralfile_exhibition_v4.js index 535c610..2108839 100644 --- a/test/feralfile_exhibition_v4.js +++ b/test/feralfile_exhibition_v4.js @@ -1,4 +1,5 @@ const FeralfileExhibitionV4 = artifacts.require("FeralfileExhibitionV4"); +const FeralfileVault = artifacts.require("FeralfileVault"); const CONTRACT_URI = "https://ipfs.bitmark.com/ipfs/QmaptARVxNSP36PQai5oiCPqbrATvpydcJ8SPx6T6Yp1CZ"; @@ -11,15 +12,20 @@ const originArtworkCID = "QmQPeNsJPyVWPFDVHb77w8G42Fvo15z4bG2X8D2GhfbSXc"; contract("FeralfileExhibitionV4", async (accounts) => { before(async function () { + this.vault = await FeralfileVault.new(); + this.exhibition = await FeralfileExhibitionV4.new( "Feral File V4 Test 001", "FFV4", CONTRACT_URI, IPFS_GATEWAY_PREFIX, accounts[1], + this.vault.address, true, true ); + + await this.vault.setExhibitionContract(this.exhibition.address); }); it("check contract is burnable", async function () { @@ -39,6 +45,7 @@ contract("FeralfileExhibitionV4", async (accounts) => { CONTRACT_URI, IPFS_GATEWAY_PREFIX, accounts[1], + this.vault.address, false, true ); @@ -53,6 +60,7 @@ contract("FeralfileExhibitionV4", async (accounts) => { CONTRACT_URI, IPFS_GATEWAY_PREFIX, accounts[1], + this.vault.address, true, false ); @@ -62,15 +70,23 @@ contract("FeralfileExhibitionV4", async (accounts) => { it("mint artworks successfully", async function () { try { + // Mint for buy by crypto await this.exhibition.mintArtworks([ - [0, 0, "CID_1"], - [0, 1, "CID_2"], - [1, 0, "CID_3"], - [1, 1, "CID_4"], - [1, 2, "CID_5"], + [1, 0, "CID_1"], + [1, 1, "CID_2"], + [2, 0, "CID_3"], + [2, 1, "CID_4"], + [2, 2, "CID_5"], + ]); + // Mint for credit card + await this.exhibition.mintArtworks([ + [1, 2, "CID_1"], + [1, 3, "CID_2"], + [2, 3, "CID_3"], + [2, 4, "CID_4"], ]); const totalSupply = await this.exhibition.totalSupply(); - assert.equal(totalSupply, 5); + assert.equal(totalSupply, 9); const ownerOfToken0 = await this.exhibition.ownerOf(1000000); const ownerOfToken1 = await this.exhibition.ownerOf(1000001); @@ -78,22 +94,43 @@ contract("FeralfileExhibitionV4", async (accounts) => { const ownerOfToken3 = await this.exhibition.ownerOf(2000001); const ownerOfToken4 = await this.exhibition.ownerOf(2000002); + const ownerOfToken5 = await this.exhibition.ownerOf(1000002); + const ownerOfToken6 = await this.exhibition.ownerOf(1000003); + const ownerOfToken7 = await this.exhibition.ownerOf(2000003); + const ownerOfToken8 = await this.exhibition.ownerOf(2000004); + assert.equal(ownerOfToken0, this.exhibition.address); assert.equal(ownerOfToken1, this.exhibition.address); assert.equal(ownerOfToken2, this.exhibition.address); assert.equal(ownerOfToken3, this.exhibition.address); assert.equal(ownerOfToken4, this.exhibition.address); + assert.equal(ownerOfToken5, this.exhibition.address); + assert.equal(ownerOfToken6, this.exhibition.address); + assert.equal(ownerOfToken7, this.exhibition.address); + assert.equal(ownerOfToken8, this.exhibition.address); + const tokenURIOfToken0 = await this.exhibition.tokenURI(1000000); const tokenURIOfToken1 = await this.exhibition.tokenURI(1000001); const tokenURIOfToken2 = await this.exhibition.tokenURI(2000000); const tokenURIOfToken3 = await this.exhibition.tokenURI(2000001); const tokenURIOfToken4 = await this.exhibition.tokenURI(2000002); + + const tokenURIOfToken5 = await this.exhibition.tokenURI(1000002); + const tokenURIOfToken6 = await this.exhibition.tokenURI(1000003); + const tokenURIOfToken7 = await this.exhibition.tokenURI(2000003); + const tokenURIOfToken8 = await this.exhibition.tokenURI(2000004); + assert.equal(tokenURIOfToken0, "ipfs://CID_1"); assert.equal(tokenURIOfToken1, "ipfs://CID_2"); assert.equal(tokenURIOfToken2, "ipfs://CID_3"); assert.equal(tokenURIOfToken3, "ipfs://CID_4"); assert.equal(tokenURIOfToken4, "ipfs://CID_5"); + + assert.equal(tokenURIOfToken5, "ipfs://CID_1"); + assert.equal(tokenURIOfToken6, "ipfs://CID_2"); + assert.equal(tokenURIOfToken7, "ipfs://CID_3"); + assert.equal(tokenURIOfToken8, "ipfs://CID_4"); } catch (err) { console.log(err); assert.fail(); @@ -104,13 +141,40 @@ contract("FeralfileExhibitionV4", async (accounts) => { // Generate signature const expiryTime = (new Date().getTime() / 1000 + 300).toFixed(0); const signParams = web3.eth.abi.encodeParameters( - ["address", "uint256[]", "uint", "uint", "uint"], [ - accounts[2], - [1000000, 1000001, 2000000, 2000001, 2000002], - BigInt(0.25 * 1e18).toString(), - BigInt(0.23 * 1e18).toString(), - expiryTime, + "tuple(uint256,uint256,uint256,address,uint256[],tuple(address,uint256)[][],bool)", + ], + [ + [ + BigInt(0.25 * 1e18).toString(), + BigInt(0.25 * 1e18).toString(), + expiryTime, + accounts[2], + [1000000, 1000001, 2000000, 2000001, 2000002], + [ + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + ], + false, + ], ] ); const hash = web3.utils.keccak256(signParams); @@ -130,18 +194,37 @@ contract("FeralfileExhibitionV4", async (accounts) => { r, s, web3.utils.toDecimal(v) + 27, // magic 27 - accounts[2], - [1000000, 1000001, 2000000, 2000001, 2000002], [ BigInt(0.25 * 1e18).toString(), - BigInt(0.23 * 1e18).toString(), + BigInt(0.25 * 1e18).toString(), expiryTime, + accounts[2], + [1000000, 1000001, 2000000, 2000001, 2000002], [ - [accounts[3], 8000], - [accounts[4], 2000], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], ], + false, ], - { from: accounts[5], value: 0.23 * 1e18 } + { from: accounts[5], value: 0.25 * 1e18 } ); const ownerOfToken0 = await this.exhibition.ownerOf(1000000); const ownerOfToken1 = await this.exhibition.ownerOf(1000001); @@ -161,13 +244,128 @@ contract("FeralfileExhibitionV4", async (accounts) => { ( BigInt(acc3BalanceAfter) - BigInt(acc3BalanceBefore) ).toString(), - BigInt((0.23 * 1e18 * 80) / 100).toString() + BigInt((0.25 * 1e18 * 80) / 100).toString() + ); + assert.equal( + ( + BigInt(acc4BalanceAfter) - BigInt(acc4BalanceBefore) + ).toString(), + BigInt((0.25 * 1e18 * 20) / 100).toString() + ); + } catch (err) { + console.log(err); + assert.fail(); + } + }); + + it("test buy artworks successfully with vault contract", async function () { + await web3.eth.sendTransaction({ + to: this.vault.address, + from: accounts[8], + value: BigInt(0.2 * 1e18).toString(), + }); + // Generate signature + const expiryTime = (new Date().getTime() / 1000 + 300).toFixed(0); + const signParams = web3.eth.abi.encodeParameters( + [ + "tuple(uint256,uint256,uint256,address,uint256[],tuple(address,uint256)[][],bool)", + ], + [ + [ + "0", + BigInt(0.2 * 1e18).toString(), + expiryTime, + accounts[2], + [1000002, 1000003, 2000003, 2000004], + [ + [ + [accounts[3], 5000], + [accounts[4], 5000], + ], + [ + [accounts[3], 5000], + [accounts[4], 5000], + ], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + ], + true, + ], + ] + ); + const hash = web3.utils.keccak256(signParams); + var sig = await web3.eth.sign(hash, accounts[1]); + 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); + // Generate signature + try { + const acc3BalanceBefore = await web3.eth.getBalance(accounts[3]); + const acc4BalanceBefore = await web3.eth.getBalance(accounts[4]); + + await this.exhibition.startSale(); + await this.exhibition.buyArtworks( + r, + s, + web3.utils.toDecimal(v) + 27, // magic 27 + [ + "0", + BigInt(0.2 * 1e18).toString(), + expiryTime, + accounts[2], + [1000002, 1000003, 2000003, 2000004], + [ + [ + [accounts[3], 5000], + [accounts[4], 5000], + ], + [ + [accounts[3], 5000], + [accounts[4], 5000], + ], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + [ + [accounts[3], 8000], + [accounts[4], 2000], + ], + ], + true, + ], + { from: accounts[5], value: "0" } + ); + const ownerOfToken0 = await this.exhibition.ownerOf(1000002); + const ownerOfToken1 = await this.exhibition.ownerOf(1000003); + const ownerOfToken2 = await this.exhibition.ownerOf(2000003); + const ownerOfToken3 = await this.exhibition.ownerOf(2000004); + assert.equal(ownerOfToken0, accounts[2]); + assert.equal(ownerOfToken1, accounts[2]); + assert.equal(ownerOfToken2, accounts[2]); + assert.equal(ownerOfToken3, accounts[2]); + + const acc3BalanceAfter = await web3.eth.getBalance(accounts[3]); + const acc4BalanceAfter = await web3.eth.getBalance(accounts[4]); + + assert.equal( + ( + BigInt(acc3BalanceAfter) - BigInt(acc3BalanceBefore) + ).toString(), + BigInt(((0.2 / 4) * 2 * 1e18 * 130) / 100).toString() ); assert.equal( ( BigInt(acc4BalanceAfter) - BigInt(acc4BalanceBefore) ).toString(), - BigInt((0.23 * 1e18 * 20) / 100).toString() + BigInt(((0.2 / 4) * 2 * 1e18 * 70) / 100).toString() ); } catch (err) { console.log(err);