--- a/tests/package.json +++ b/tests/package.json @@ -125,6 +125,7 @@ "license": "SEE LICENSE IN ../LICENSE", "homepage": "", "dependencies": { + "@openzeppelin/contracts": "^4.9.0", "@polkadot/api": "10.7.2", "@polkadot/rpc-core": "^10.7.2", "@polkadot/util": "12.2.1", --- /dev/null +++ b/tests/src/eth/marketplace-v2/Market.sol @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +import "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { UniqueNFT, CrossAddress } from "@unique-nft/solidity-interfaces/contracts/UniqueNFT.sol"; +import "@unique-nft/solidity-interfaces/contracts/CollectionHelpers.sol"; +import "./royalty/UniqueRoyaltyHelper.sol"; + +contract Market { + using ERC165Checker for address; + + struct Order { + uint32 id; + uint32 collectionId; + uint32 tokenId; + uint32 amount; + uint256 price; + CrossAddress seller; + } + + uint32 public constant version = 0; + bytes4 private constant InterfaceId_ERC721 = 0x80ac58cd; + bytes4 private constant InterfaceId_ERC165 = 0x5755c3f2; + CollectionHelpers private constant collectionHelpers = + CollectionHelpers(0x6C4E9fE1AE37a41E93CEE429e8E1881aBdcbb54F); + + mapping(uint32 => mapping(uint32 => Order)) orders; + uint32 private idCount = 1; + uint32 public marketFee; + uint64 public ctime; + address selfAddress; + address public ownerAddress; + mapping(address => bool) public admins; + + event TokenIsUpForSale(uint32 version, Order item); + event TokenRevoke(uint32 version, Order item, uint32 amount); + event TokenIsApproved(uint32 version, Order item); + event TokenIsPurchased( + uint32 version, + Order item, + uint32 salesAmount, + CrossAddress buyer, + RoyaltyAmount[] royalties + ); + event Log(string message); + + error InvalidArgument(string info); + error InvalidMarketFee(); + error SellerIsNotOwner(); + error TokenIsAlreadyOnSale(); + error TokenIsNotApproved(); + error CollectionNotFound(); + error CollectionNotSupportedERC721(); + error OrderNotFound(); + error TooManyAmountRequested(); + error NotEnoughMoneyError(); + error FailTransferToken(string reason); + + modifier onlyOwner() { + require(msg.sender == ownerAddress, "Only owner can"); + _; + } + + modifier onlyAdmin() { + require(msg.sender == ownerAddress || admins[msg.sender], "Only admin can"); + _; + } + + modifier validCrossAddress(address eth, uint256 sub) { + if (eth == address(0) && sub == 0) { + revert InvalidArgument("Ethereum and Substrate addresses cannot be null at the same time"); + } + + if (eth != address(0) && sub != 0) { + revert InvalidArgument("Ethereum and Substrate addresses cannot be not null at the same time"); + } + + _; + } + + constructor(uint32 fee, uint64 timestamp) { + marketFee = fee; + ctime = timestamp; + + if (marketFee == 0 || marketFee >= 100) { + revert InvalidMarketFee(); + } + + ownerAddress = msg.sender; + selfAddress = address(this); + } + + function getErc721(uint32 collectionId) private view returns (IERC721) { + address collectionAddress = collectionHelpers.collectionAddress( + collectionId + ); + + uint size; + assembly { + size := extcodesize(collectionAddress) + } + + if (size == 0) { + revert CollectionNotFound(); + } + + if (!collectionAddress.supportsInterface(InterfaceId_ERC721)) { + revert CollectionNotSupportedERC721(); + } + + return IERC721(collectionAddress); + } + + // ################################################################ + // Set new contract owner # + // ################################################################ + + function setOwner() public onlyOwner { + ownerAddress = msg.sender; + } + + // ################################################################ + // Add new admin # + // ################################################################ + + function addAdmin(address admin) public onlyAdmin { + admins[admin] = true; + } + + // ################################################################ + // Remove admin # + // ################################################################ + + function removeAdmin(address admin) public onlyAdmin { + delete admins[admin]; + } + + // ################################################################ + // Place a token for sale # + // ################################################################ + + function put( + uint32 collectionId, + uint32 tokenId, + uint256 price, + uint32 amount, + CrossAddress memory seller + ) public validCrossAddress(seller.eth, seller.sub) { + if (price == 0) { + revert InvalidArgument("price must not be zero"); + } + if (amount == 0) { + revert InvalidArgument("amount must not be zero"); + } + + if (orders[collectionId][tokenId].price > 0) { + revert TokenIsAlreadyOnSale(); + } + + IERC721 erc721 = getErc721(collectionId); + + if (erc721.ownerOf(tokenId) != msg.sender) { + revert SellerIsNotOwner(); + } + + if (erc721.getApproved(tokenId) != selfAddress) { + revert TokenIsNotApproved(); + } + + Order memory order = Order( + 0, + collectionId, + tokenId, + amount, + price, + seller + ); + + order.id = idCount++; + orders[collectionId][tokenId] = order; + + emit TokenIsUpForSale(version, order); + } + + // ################################################################ + // Get order # + // ################################################################ + + function getOrder( + uint32 collectionId, + uint32 tokenId + ) external view returns (Order memory) { + return orders[collectionId][tokenId]; + } + + // ################################################################ + // Revoke the token from the sale # + // ################################################################ + + function revoke( + uint32 collectionId, + uint32 tokenId, + uint32 amount + ) external { + if (amount == 0) { + revert InvalidArgument("amount must not be zero"); + } + + Order memory order = orders[collectionId][tokenId]; + + if (order.price == 0) { + revert OrderNotFound(); + } + + if (amount > order.amount) { + revert TooManyAmountRequested(); + } + + IERC721 erc721 = getErc721(collectionId); + + address ethAddress; + if (order.seller.eth != address(0)) { + ethAddress = order.seller.eth; + } else { + ethAddress = payable(address(uint160(order.seller.sub >> 96))); + } + if (erc721.ownerOf(tokenId) != ethAddress) { + revert SellerIsNotOwner(); + } + + order.amount -= amount; + if (order.amount == 0) { + delete orders[collectionId][tokenId]; + } else { + orders[collectionId][tokenId] = order; + } + + emit TokenRevoke(version, order, amount); + } + + // ################################################################ + // Check approved # + // ################################################################ + + function checkApproved(uint32 collectionId, uint32 tokenId) public onlyAdmin { + Order memory order = orders[collectionId][tokenId]; + if (order.price == 0) { + revert OrderNotFound(); + } + + IERC721 erc721 = getErc721(collectionId); + + if (erc721.getApproved(tokenId) != selfAddress) { + uint32 amount = order.amount; + order.amount = 0; + emit TokenRevoke(version, order, amount); + + delete orders[collectionId][tokenId]; + } else { + emit TokenIsApproved(version, order); + } + } + + // ################################################################ + // Buy a token # + // ################################################################ + + function buy( + uint32 collectionId, + uint32 tokenId, + uint32 amount, + CrossAddress memory buyer + ) public payable validCrossAddress(buyer.eth, buyer.sub) { + if (msg.value == 0) { + revert InvalidArgument("msg.value must not be zero"); + } + if (amount == 0) { + revert InvalidArgument("amount must not be zero"); + } + + Order memory order = orders[collectionId][tokenId]; + if (order.price == 0) { + revert OrderNotFound(); + } + + if (amount > order.amount) { + revert TooManyAmountRequested(); + } + + uint256 totalValue = order.price * amount; + uint256 feeValue = (totalValue * marketFee) / 100; + + if (msg.value < totalValue) { + revert NotEnoughMoneyError(); + } + + IERC721 erc721 = getErc721(order.collectionId); + if (erc721.getApproved(tokenId) != selfAddress) { + revert TokenIsNotApproved(); + } + + order.amount -= amount; + if (order.amount == 0) { + delete orders[collectionId][tokenId]; + } else { + orders[collectionId][tokenId] = order; + } + + address collectionAddress = collectionHelpers.collectionAddress(collectionId); + UniqueNFT nft = UniqueNFT(collectionAddress); + + nft.transferFromCross( + order.seller, + buyer, + order.tokenId + ); + + (uint256 totalRoyalty, RoyaltyAmount[] memory royalties) = sendRoyalties(collectionAddress, tokenId, totalValue); + + sendMoney(order.seller, totalValue - feeValue - totalRoyalty); + + if (msg.value > totalValue) { + // todo, send money to signer or buyer ? + payable(msg.sender).transfer(msg.value - totalValue); + } + + emit TokenIsPurchased(version, order, amount, buyer, royalties); + } + + function sendMoney(CrossAddress memory to, uint256 money) private { + address payable eth; + if (to.eth != address(0)) { + eth = payable(to.eth); + } else { + eth = payable(address(uint160(to.sub >> 96))); + } + eth.transfer(money); + } + + function sendRoyalties(address collection, uint tokenId, uint sellPrice) private returns (uint256, RoyaltyAmount[] memory) { + RoyaltyAmount[] memory royalties = UniqueRoyaltyHelper.calculate(collection, tokenId, sellPrice); + + uint256 totalRoyalty = 0; + + for (uint256 i=0; i 0) { + payable(transferTo).transfer(balance); + } + } +} \ No newline at end of file --- /dev/null +++ b/tests/src/eth/marketplace-v2/marketplace.test.ts @@ -0,0 +1,152 @@ +// Copyright 2019-2022 Unique Network (Gibraltar) Ltd. +// This file is part of Unique Network. + +// Unique Network is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Unique Network is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Unique Network. If not, see . + +import {IKeyringPair} from '@polkadot/types/types'; +import {readFile} from 'fs/promises'; +import {EthUniqueHelper, itEth, usingEthPlaygrounds} from '../util'; +import {makeNames} from '../../util'; +import {expect} from 'chai'; +import Web3 from 'web3'; + +const {dirname} = makeNames(import.meta.url); + +describe('Market V2 Contract', () => { + let donor: IKeyringPair; + + before(async () => { + await usingEthPlaygrounds(async (_helper, privateKey) => { + donor = await privateKey({url: import.meta.url}); + }); + }); + + async function deployMarket(helper: EthUniqueHelper, marketOwner: string) { + return await helper.ethContract.deployByCode( + marketOwner, + 'Market', + (await readFile(`${dirname}/Market.sol`)).toString(), + [ + { + solPath: '@unique-nft/solidity-interfaces/contracts/UniqueNFT.sol', + fsPath: `${dirname}/../api/UniqueNFT.sol`, + }, + { + solPath: '@openzeppelin/contracts/utils/introspection/IERC165.sol', + fsPath: `${dirname}/../../../node_modules/@openzeppelin/contracts/utils/introspection/IERC165.sol`, + }, + { + solPath: '@openzeppelin/contracts/utils/introspection/ERC165Checker.sol', + fsPath: `${dirname}/../../../node_modules/@openzeppelin/contracts/utils/introspection/ERC165Checker.sol`, + }, + { + solPath: '@openzeppelin/contracts/token/ERC721/IERC721.sol', + fsPath: `${dirname}/../../../node_modules/@openzeppelin/contracts/token/ERC721/IERC721.sol`, + }, + { + solPath: '@unique-nft/solidity-interfaces/contracts/CollectionHelpers.sol', + fsPath: `${dirname}/../api/CollectionHelpers.sol`, + }, + { + solPath: 'royalty/UniqueRoyaltyHelper.sol', + fsPath: `${dirname}/royalty/UniqueRoyaltyHelper.sol`, + }, + { + solPath: 'royalty/UniqueRoyalty.sol', + fsPath: `${dirname}/royalty/UniqueRoyalty.sol`, + }, + { + solPath: 'royalty/LibPart.sol', + fsPath: `${dirname}/royalty/LibPart.sol`, + }, + ], + 15000000, + [1, 0], + ); + } + + function substrateAddressToHex(sub: Uint8Array| string, web3: Web3) { + if(typeof sub === 'string') + return web3.utils.padLeft(web3.utils.toHex(web3.utils.toBN(sub)), 64); + else if(sub instanceof Uint8Array) + return web3.utils.padLeft(web3.utils.bytesToHex(Array.from(sub)), 64); + } + + itEth('Deploy', async ({helper}) => { + const marketOwner = await helper.eth.createAccountWithBalance(donor, 600n); + + await deployMarket(helper, marketOwner); + }); + + itEth('Put + Buy [eth]', async ({helper}) => { + const marketOwner = await helper.eth.createAccountWithBalance(donor, 600n); + const market = await deployMarket(helper, marketOwner); + + const {collectionId, collectionAddress} = await helper.eth.createNFTCollection(marketOwner, 'Sponsor', 'absolutely anything', 'ROC'); + const collection = await helper.ethNativeContract.collection(collectionAddress, 'nft', marketOwner); + + const sellerCross = await helper.ethCrossAccount.createAccountWithBalance(donor, 600n); + const result = await collection.methods.mintCross(sellerCross, []).send(); + const tokenId = result.events.Transfer.returnValues.tokenId; + await collection.methods.approve(market.options.address, tokenId).send({from: sellerCross.eth}); + + const putResult = await market.methods.put(collectionId, tokenId, 1, 1, sellerCross).send({from: sellerCross.eth}); + expect(putResult.events.TokenIsUpForSale).is.not.undefined; + let ownerCross = await collection.methods.ownerOfCross(tokenId).call(); + expect(ownerCross.eth).to.be.eq(sellerCross.eth); + expect(ownerCross.sub).to.be.eq(sellerCross.sub); + + const buyerCross = await helper.ethCrossAccount.createAccountWithBalance(donor, 600n); + const buyResult = await market.methods.buy(collectionId, tokenId, 1, buyerCross).send({from: buyerCross.eth, value: 1}); + expect(buyResult.events.TokenIsPurchased).is.not.undefined; + ownerCross = await collection.methods.ownerOfCross(tokenId).call(); + expect(ownerCross.eth).to.be.eq(buyerCross.eth); + expect(ownerCross.sub).to.be.eq(buyerCross.sub); + }); + + itEth('Put + Buy [sub]', async ({helper}) => { + const PRICE = 1n; + const web3 = helper.getWeb3(); + const marketOwner = await helper.eth.createAccountWithBalance(donor, 600n); + const market = await deployMarket(helper, marketOwner); + + const {collectionId, collectionAddress} = await helper.eth.createNFTCollection(marketOwner, 'Sponsor', 'absolutely anything', 'ROC'); + const collection = await helper.ethNativeContract.collection(collectionAddress, 'nft', marketOwner); + + const [seller] = await helper.arrange.createAccounts([600n], donor); + const sellerMirror = helper.address.substrateToEth(seller.address); + const sellerCross = helper.ethCrossAccount.fromKeyringPair(seller); + const result = await collection.methods.mintCross(sellerCross, []).send(); + const tokenId = result.events.Transfer.returnValues.tokenId; + await helper.nft.approveToken(seller, collectionId, tokenId, {Ethereum: market.options.address}, 1n); + + await helper.eth.sendEVM(seller, market.options.address, market.methods.put(collectionId, tokenId, PRICE, 1, sellerCross).encodeABI(), '0'); + let ownerCross = await collection.methods.ownerOfCross(tokenId).call(); + expect(ownerCross.eth).to.be.eq(sellerCross.eth); + expect(substrateAddressToHex(ownerCross.sub, web3)).to.be.eq(substrateAddressToHex(sellerCross.sub, web3)); + + const [buyer] = await helper.arrange.createAccounts([600n], donor); + const buyerMirror = helper.address.substrateToEth(buyer.address); + const buyerCross = helper.ethCrossAccount.fromKeyringPair(buyer); + await helper.eth.transferBalanceFromSubstrate(donor, buyerMirror, 1n); + //TODO: change balance check to helper.balance.getSubstrate when implementation of sendMoney will be fixed in contract + const sellerBalance = BigInt(await web3.eth.getBalance(sellerMirror)); + await helper.eth.sendEVM(buyer, market.options.address, market.methods.buy(collectionId, tokenId, 1, buyerCross).encodeABI(), PRICE.toString()); + const sellerBalanceAfterBuy = BigInt(await web3.eth.getBalance(sellerMirror)); + ownerCross = await collection.methods.ownerOfCross(tokenId).call(); + expect(ownerCross.eth).to.be.eq(buyerCross.eth); + expect(substrateAddressToHex(ownerCross.sub, web3)).to.be.eq(substrateAddressToHex(buyerCross.sub, web3)); + expect(sellerBalance + PRICE).to.be.equal(sellerBalanceAfterBuy); + }); +}); --- /dev/null +++ b/tests/src/eth/marketplace-v2/royalty/LibPart.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./UniqueRoyalty.sol"; + +library LibPart { + bytes32 public constant TYPE_HASH = keccak256("Part(address account,uint96 value)"); + + struct Part { + address payable account; + uint96 value; + } + + function hash(Part memory part) internal pure returns (bytes32) { + return keccak256(abi.encode(TYPE_HASH, part.account, part.value)); + } +} + +library LibPartAdapter { + function encode(LibPart.Part[] memory parts) internal pure returns (bytes memory) { + if (parts.length == 0) return ""; + + uint256[] memory encoded = new uint256[](parts.length * 2); + + for (uint i = 0; i < parts.length; i++) { + encoded[i * 2] = 0x0100000000000000000000000000000000000000000000040000000000000000 | uint256(parts[i].value); + encoded[i * 2 + 1] = uint256(uint160(address(parts[i].account))); + } + + return abi.encodePacked(encoded); + } + + function decode(bytes memory b) internal pure returns (LibPart.Part[] memory) { + if (b.length == 0) return new LibPart.Part[](0); + + require((b.length % (32 * 2)) == 0, "Invalid bytes length, expected (32 * 2) * UniqueRoyaltyParts count"); + uint partsCount = b.length / (32 * 2); + uint numbersCount = partsCount * 2; + + LibPart.Part[] memory parts = new LibPart.Part[](partsCount); + + // need this because numbers encoded via abi.encodePacked + bytes memory prefix = new bytes(64); + + assembly { + mstore(add(prefix, 32), 32) + mstore(add(prefix, 64), numbersCount) + } + + uint256[] memory encoded = abi.decode(bytes.concat(prefix, b), (uint256[])); + + for (uint i = 0; i < partsCount; i++) { + uint96 value = uint96(encoded[i * 2] & 0xFFFFFFFFFFFFFFFF); + address account = address(uint160(encoded[i * 2 + 1])); + + parts[i] = LibPart.Part({ + account: payable(account), + value: value + }); + } + + return parts; + } +} + +library LibPartAdapterComplex { + function decodeSafe(bytes memory data) internal pure returns (LibPart.Part[] memory) { + return fromUniqueRoyalties(UniqueRoyalty.decode(data)); + } + + function encodeSafe(LibPart.Part[] memory parts) internal pure returns (bytes memory) { + return UniqueRoyalty.encode(toUniqueRoyalties(parts)); + } + + function fromUniqueRoyalties(UniqueRoyaltyPart[] memory royalties) internal pure returns (LibPart.Part[] memory) { + LibPart.Part[] memory parts = new LibPart.Part[](royalties.length); + + for (uint i = 0; i < royalties.length; i++) { + uint96 value = royalties[i].decimals >= 4 + ? uint96(royalties[i].value * (10 ** (royalties[i].decimals - 4))) + : uint96(royalties[i].value / (10 ** (4 - royalties[i].decimals))); + + parts[i] = LibPart.Part({ + account: payable(CrossAddressLib.toAddress(royalties[i].crossAddress)), + value: value + }); + } + + return parts; + } + + function toUniqueRoyalties(LibPart.Part[] memory parts) internal pure returns (UniqueRoyaltyPart[] memory) { + UniqueRoyaltyPart[] memory royalties = new UniqueRoyaltyPart[](parts.length); + + for (uint i = 0; i < parts.length; i++) { + royalties[i] = UniqueRoyaltyPart({ + version: 1, + value: uint64(parts[i].value), + decimals: 4, + crossAddress: CrossAddress({ + sub: 0, + eth: parts[i].account + }), + isPrimarySaleOnly: false + }); + } + + return royalties; + } +} \ No newline at end of file --- /dev/null +++ b/tests/src/eth/marketplace-v2/royalty/UniqueRoyalty.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.17; + +import { CrossAddress } from "@unique-nft/solidity-interfaces/contracts/UniqueNFT.sol"; + +struct UniqueRoyaltyPart { + uint8 version; + uint8 decimals; + uint64 value; + bool isPrimarySaleOnly; + CrossAddress crossAddress; +} + +library CrossAddressLib { + function toAddress(CrossAddress memory crossAddress) internal pure returns (address) { + return crossAddress.eth != address(0) ? crossAddress.eth : address(uint160(crossAddress.sub >> 96)); + } +} + +library UniqueRoyalty { + uint private constant DECIMALS_OFFSET = 4 * 16; + uint private constant ADDRESS_TYPE_OFFSET = 4 * (16 + 2); // 0 - eth, 1 - sub + uint private constant ROYALTY_TYPE_OFFSET = 4 * (16 + 2 + 1); // 0 - default, 1 - primary sale only + uint private constant VERSION_OFFSET = 4 * (16 + 2 + 1 + 1 + 42); + + uint private constant PART_LENGTH = 32 * 2; + + function decode(bytes memory b) internal pure returns (UniqueRoyaltyPart[] memory) { + if (b.length == 0) return new UniqueRoyaltyPart[](0); + + require((b.length % PART_LENGTH) == 0, "Invalid bytes length, expected (32 * 2) * UniqueRoyaltyParts count"); + uint partsCount = b.length / PART_LENGTH; + uint numbersCount = partsCount * 2; + + UniqueRoyaltyPart[] memory parts = new UniqueRoyaltyPart[](partsCount); + + // need this because numbers encoded via abi.encodePacked + bytes memory prefix = new bytes(64); + + assembly { + mstore(add(prefix, 32), 32) + mstore(add(prefix, 64), numbersCount) + } + + uint256[] memory encoded = abi.decode(bytes.concat(prefix, b), (uint256[])); + + for (uint i = 0; i < partsCount; i++) { + parts[i] = decodePart(encoded[i * 2], encoded[i * 2 + 1]); + } + + return parts; + } + + function encode(UniqueRoyaltyPart[] memory parts) internal pure returns (bytes memory) { + if (parts.length == 0) return ""; + + uint256[] memory encoded = new uint256[](parts.length * 2); + + for (uint i = 0; i < parts.length; i++) { + (uint256 encodedMeta, uint256 encodedAddress) = encodePart(parts[i]); + + encoded[i * 2] = encodedMeta; + encoded[i * 2 + 1] = encodedAddress; + } + + return abi.encodePacked(encoded); + } + + function decodePart(bytes memory b) internal pure returns (UniqueRoyaltyPart memory) { + require(b.length == PART_LENGTH, "Invalid bytes length, expected 32 * 2"); + + uint256[2] memory encoded = abi.decode(b, (uint256[2])); + + return decodePart(encoded[0], encoded[1]); + } + + function decodePart( + uint256 _meta, + uint256 _address + ) internal pure returns (UniqueRoyaltyPart memory) { + uint256 version = _meta >> VERSION_OFFSET; + bool isPrimarySaleOnly = (_meta & (1 << ROYALTY_TYPE_OFFSET)) > 0; + bool isEthereumAddress = (_meta & (1 << ADDRESS_TYPE_OFFSET)) == 0; + uint256 decimals = (_meta >> 4 * 16) & 0xFF; + uint256 value = _meta & 0xFFFFFFFFFFFFFFFF; + + CrossAddress memory crossAddress = isEthereumAddress + ? CrossAddress({ sub: 0, eth: address(uint160(_address)) }) + : CrossAddress({ sub: _address, eth: address(0) }); + + UniqueRoyaltyPart memory royaltyPart = UniqueRoyaltyPart({ + version: uint8(version), + decimals: uint8(decimals), + value: uint64(value), + isPrimarySaleOnly: isPrimarySaleOnly, + crossAddress: crossAddress + }); + + return royaltyPart; + } + + function encodePart(UniqueRoyaltyPart memory royaltyPart) internal pure returns (uint256, uint256) { + uint256 encodedMeta = 0; + uint256 encodedAddress = 0; + + encodedMeta |= uint256(royaltyPart.version) << VERSION_OFFSET; + if (royaltyPart.isPrimarySaleOnly) encodedMeta |= 1 << ROYALTY_TYPE_OFFSET; + + if (royaltyPart.crossAddress.eth == address(0x0)) { + encodedMeta |= 1 << ADDRESS_TYPE_OFFSET; + encodedAddress = royaltyPart.crossAddress.sub; + } else { + encodedAddress = uint256(uint160(royaltyPart.crossAddress.eth)); + } + + encodedMeta |= uint256(royaltyPart.decimals) << DECIMALS_OFFSET; + encodedMeta |= uint256(royaltyPart.value); + + return (encodedMeta, encodedAddress); + } +} \ No newline at end of file --- /dev/null +++ b/tests/src/eth/marketplace-v2/royalty/UniqueRoyaltyHelper.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.17; + +import "./UniqueRoyalty.sol"; +import "./LibPart.sol"; + +string constant ROYALTIES_PROPERTY = "royalties"; + +interface ICollection { + function collectionProperty(string memory key) external view returns (bytes memory); + function property(uint256 tokenId, string memory key) external view returns (bytes memory); +} + +struct RoyaltyAmount { + CrossAddress crossAddress; + uint amount; +} + +library UniqueRoyaltyHelper { + function encodePart(UniqueRoyaltyPart memory part) internal pure returns (bytes memory) { + (uint256 encodedMeta, uint256 encodedAddress) = UniqueRoyalty.encodePart(part); + + return abi.encodePacked(encodedMeta, encodedAddress); + } + + function decodePart(bytes memory data) internal pure returns (UniqueRoyaltyPart memory) { + return UniqueRoyalty.decodePart(data); + } + + // todo - implement smth better - check royalties sum is lte 100% + function validatePart(bytes memory b) internal pure returns (bool isValid) { + isValid = b.length == 64; + } + + function encode(UniqueRoyaltyPart[] memory royalties) internal pure returns (bytes memory) { + return UniqueRoyalty.encode(royalties); + } + + function decode(bytes memory data) internal pure returns (UniqueRoyaltyPart[] memory) { + return UniqueRoyalty.decode(data); + } + + // todo - implement smth better - check royalties sum is lte 100% + function validate(bytes memory b) internal pure returns (bool) { + return b.length % 64 == 0; + } + + function getTokenRoyalty(address collection, uint tokenId) internal view returns (UniqueRoyaltyPart[] memory) { + try ICollection(collection).property(tokenId, ROYALTIES_PROPERTY) returns (bytes memory encoded) { + return UniqueRoyalty.decode(encoded); + } catch { + return new UniqueRoyaltyPart[](0); + } + } + + function getCollectionRoyalty(address collection) internal view returns (UniqueRoyaltyPart[] memory) { + try ICollection(collection).collectionProperty(ROYALTIES_PROPERTY) returns (bytes memory encoded) { + return UniqueRoyalty.decode(encoded); + } catch { + return new UniqueRoyaltyPart[](0); + } + } + + function getRoyalty(address collection, uint tokenId) internal view returns (UniqueRoyaltyPart[] memory royalty) { + royalty = getTokenRoyalty(collection, tokenId); + + if (royalty.length == 0) { + royalty = getCollectionRoyalty(collection); + } + } + + function calculateRoyalties(UniqueRoyaltyPart[] memory royalties, bool isPrimarySale, uint sellPrice) internal pure returns (RoyaltyAmount[] memory) { + RoyaltyAmount[] memory royaltyAmounts = new RoyaltyAmount[](royalties.length); + uint amountsCount = 0; + + for (uint i = 0; i < royalties.length; i++) { + if (isPrimarySale == royalties[i].isPrimarySaleOnly) { + uint amount = (sellPrice * royalties[i].value) / (10 ** (royalties[i].decimals)); + + royaltyAmounts[amountsCount] = RoyaltyAmount({ + crossAddress: royalties[i].crossAddress, + amount: amount + }); + + amountsCount += 1; + } + } + + // shrink royaltyAmounts to amountsCount length + assembly { mstore(royaltyAmounts, amountsCount) } + + return royaltyAmounts; + } + + function calculateForPrimarySale(address collection, uint tokenId, uint sellPrice) internal view returns (RoyaltyAmount[] memory) { + UniqueRoyaltyPart[] memory royalties = getRoyalty(collection, tokenId); + + return calculateRoyalties(royalties, true, sellPrice); + } + + function calculate(address collection, uint tokenId, uint sellPrice) internal view returns (RoyaltyAmount[] memory) { + UniqueRoyaltyPart[] memory royalties = getRoyalty(collection, tokenId); + + return calculateRoyalties(royalties, false, sellPrice); + } +} \ No newline at end of file --- /dev/null +++ b/tests/src/eth/marketplace-v2/utils.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +contract Utils { + function toString(address account) public pure returns (string memory) { + return toString(abi.encodePacked(account)); + } + + function toString(bool value) public pure returns (string memory) { + return value ? "true" : "false"; + } + + function toString(uint256 value) public pure returns (string memory) { + return toString(abi.encodePacked(value)); + } + + function toString(bytes32 value) public pure returns (string memory) { + return toString(abi.encodePacked(value)); + } + + function toString(bytes memory data) public pure returns (string memory) { + bytes memory alphabet = "0123456789abcdef"; + + bytes memory str = new bytes(2 + data.length * 2); + str[0] = "0"; + str[1] = "x"; + for (uint i = 0; i < data.length; i++) { + str[2 + i * 2] = alphabet[uint(uint8(data[i] >> 4))]; + str[3 + i * 2] = alphabet[uint(uint8(data[i] & 0x0f))]; + } + return string(str); + } +} \ No newline at end of file --- a/tests/src/eth/util/playgrounds/unique.dev.ts +++ b/tests/src/eth/util/playgrounds/unique.dev.ts @@ -102,19 +102,19 @@ }; } - async deployByCode(signer: string, name: string, src: string, imports?: ContractImports[], gas?: number): Promise { + async deployByCode(signer: string, name: string, src: string, imports?: ContractImports[], gas?: number, args?: any[]): Promise { const compiledContract = await this.compile(name, src, imports); - return this.deployByAbi(signer, compiledContract.abi, compiledContract.object, gas); + return this.deployByAbi(signer, compiledContract.abi, compiledContract.object, gas, args); } - async deployByAbi(signer: string, abi: any, object: string, gas?: number): Promise { + async deployByAbi(signer: string, abi: any, object: string, gas?: number, args?: any[]): Promise { const web3 = this.helper.getWeb3(); const contract = new web3.eth.Contract(abi, undefined, { data: object, from: signer, gas: gas ?? this.helper.eth.DEFAULT_GAS, }); - return await contract.deploy({data: object}).send({from: signer}); + return await contract.deploy({data: object, arguments: args}).send({from: signer}); } }