difftreelog
feat add test for market v2 deployment
in: master
8 files changed
tests/package.jsondiffbeforeafterboth--- a/tests/package.json
+++ b/tests/package.json
@@ -125,13 +125,13 @@
"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",
"@polkadot/util-crypto": "12.2.1",
"@polkadot/wasm-crypto-asmjs": "^7.2.1",
"@polkadot/wasm-crypto-wasm": "^7.2.1",
- "chai-as-promised": "^7.1.1",
"chai-like": "^1.1.1",
"csv-writer": "^1.6.0",
"find-process": "^1.4.7",
tests/src/eth/marketplace-v2/Market.soldiffbeforeafterboth--- /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<royalties.length; i++) {
+ RoyaltyAmount memory royalty = royalties[i];
+
+ totalRoyalty += royalty.amount;
+
+ sendMoney(royalty.crossAddress, royalty.amount);
+ }
+
+ return (totalRoyalty, royalties);
+ }
+
+ function withdraw(address transferTo) public onlyOwner {
+ uint256 balance = selfAddress.balance;
+
+ if (balance > 0) {
+ payable(transferTo).transfer(balance);
+ }
+ }
+}
\ No newline at end of file
tests/src/eth/marketplace-v2/marketplace.test.tsdiffbeforeafterboth--- /dev/null
+++ b/tests/src/eth/marketplace-v2/marketplace.test.ts
@@ -0,0 +1,78 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+import {IKeyringPair} from '@polkadot/types/types';
+import {readFile} from 'fs/promises';
+import {itEth, usingEthPlaygrounds} from '../util';
+import {makeNames} from '../../util';
+
+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});
+ });
+ });
+
+ itEth('Deploy', async ({helper}) => {
+ const matcherOwner = await helper.eth.createAccountWithBalance(donor, 600n);
+
+ await helper.ethContract.deployByCode(
+ matcherOwner,
+ '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],
+ );
+ });
+});
tests/src/eth/marketplace-v2/royalty/LibPart.soldiffbeforeafterboth--- /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
tests/src/eth/marketplace-v2/royalty/UniqueRoyalty.soldiffbeforeafterboth--- /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
tests/src/eth/marketplace-v2/royalty/UniqueRoyaltyHelper.soldiffbeforeafterboth--- /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
tests/src/eth/marketplace-v2/utils.soldiffbeforeafterboth--- /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
tests/src/eth/util/playgrounds/unique.dev.tsdiffbeforeafterboth1// Copyright 2019-2022 Unique Network (Gibraltar) Ltd.2// SPDX-License-Identifier: Apache-2.034/* eslint-disable function-call-argument-newline */5// eslint-disable-next-line @typescript-eslint/triple-slash-reference6/// <reference path="unique.dev.d.ts" />78import {readFile} from 'fs/promises';910import Web3 from 'web3';11import {WebsocketProvider} from 'web3-core';12import {Contract} from 'web3-eth-contract';1314import solc from 'solc';1516import {evmToAddress} from '@polkadot/util-crypto';17import {IKeyringPair} from '@polkadot/types/types';1819import {ArrangeGroup, DevUniqueHelper} from '../../../util/playgrounds/unique.dev';2021import {ContractImports, CompiledContract, CrossAddress, NormalizedEvent, EthProperty} from './types';2223// Native contracts ABI24import collectionHelpersAbi from '../../abi/collectionHelpers.json' assert {type: 'json'};25import nativeFungibleAbi from '../../abi/nativeFungible.json' assert {type: 'json'};26import fungibleAbi from '../../abi/fungible.json' assert {type: 'json'};27import fungibleDeprecatedAbi from '../../abi/fungibleDeprecated.json' assert {type: 'json'};28import nonFungibleAbi from '../../abi/nonFungible.json' assert {type: 'json'};29import nonFungibleDeprecatedAbi from '../../abi/nonFungibleDeprecated.json' assert {type: 'json'};30import refungibleAbi from '../../abi/reFungible.json' assert {type: 'json'};31import refungibleDeprecatedAbi from '../../abi/reFungibleDeprecated.json' assert {type: 'json'};32import refungibleTokenAbi from '../../abi/reFungibleToken.json' assert {type: 'json'};33import refungibleTokenDeprecatedAbi from '../../abi/reFungibleTokenDeprecated.json' assert {type: 'json'};34import contractHelpersAbi from '../../abi/contractHelpers.json' assert {type: 'json'};35import {ICrossAccountId, TEthereumAccount} from '../../../util/playgrounds/types';36import {TCollectionMode} from '../../../util/playgrounds/types';3738class EthGroupBase {39 helper: EthUniqueHelper;40 gasPrice?: string;4142 constructor(helper: EthUniqueHelper) {43 this.helper = helper;44 }45 async getGasPrice() {46 if(this.gasPrice)47 return this.gasPrice;48 this.gasPrice = await this.helper.getWeb3().eth.getGasPrice();49 return this.gasPrice;50 }51}525354class ContractGroup extends EthGroupBase {55 async findImports(imports?: ContractImports[]) {56 if(!imports) return function(path: string) {57 return {error: `File not found: ${path}`};58 };5960 const knownImports = {} as { [key: string]: string };61 for(const imp of imports) {62 knownImports[imp.solPath] = (await readFile(imp.fsPath)).toString();63 }6465 return function(path: string) {66 if(path in knownImports) return {contents: knownImports[path]};67 return {error: `File not found: ${path}`};68 };69 }7071 async compile(name: string, src: string, imports?: ContractImports[]): Promise<CompiledContract> {72 const compiled = JSON.parse(solc.compile(JSON.stringify({73 language: 'Solidity',74 sources: {75 [`${name}.sol`]: {76 content: src,77 },78 },79 settings: {80 outputSelection: {81 '*': {82 '*': ['*'],83 },84 },85 },86 }), {import: await this.findImports(imports)}));8788 const hasErrors = compiled['errors']89 && compiled['errors'].length > 090 && compiled.errors.some(function(err: any) {91 return err.severity == 'error';92 });9394 if(hasErrors) {95 throw compiled.errors;96 }97 const out = compiled.contracts[`${name}.sol`][name];9899 return {100 abi: out.abi,101 object: '0x' + out.evm.bytecode.object,102 };103 }104105 async deployByCode(signer: string, name: string, src: string, imports?: ContractImports[], gas?: number): Promise<Contract> {106 const compiledContract = await this.compile(name, src, imports);107 return this.deployByAbi(signer, compiledContract.abi, compiledContract.object, gas);108 }109110 async deployByAbi(signer: string, abi: any, object: string, gas?: number): Promise<Contract> {111 const web3 = this.helper.getWeb3();112 const contract = new web3.eth.Contract(abi, undefined, {113 data: object,114 from: signer,115 gas: gas ?? this.helper.eth.DEFAULT_GAS,116 });117 return await contract.deploy({data: object}).send({from: signer});118 }119120}121122class NativeContractGroup extends EthGroupBase {123124 contractHelpers(caller: string) {125 const web3 = this.helper.getWeb3();126 return new web3.eth.Contract(contractHelpersAbi as any, this.helper.getApi().consts.evmContractHelpers.contractAddress.toString(), {127 from: caller,128 gas: this.helper.eth.DEFAULT_GAS,129 });130 }131132 collectionHelpers(caller: string) {133 const web3 = this.helper.getWeb3();134 return new web3.eth.Contract(collectionHelpersAbi as any, this.helper.getApi().consts.common.contractAddress.toString(), {135 from: caller,136 gas: this.helper.eth.DEFAULT_GAS,137 });138 }139140 collection(address: string, mode: TCollectionMode, caller?: string, mergeDeprecated = false) {141 let abi;142 if(address === this.helper.ethAddress.fromCollectionId(0)) {143 abi = nativeFungibleAbi;144 } else {145 abi ={146 'nft': nonFungibleAbi,147 'rft': refungibleAbi,148 'ft': fungibleAbi,149 }[mode];150 }151 if(mergeDeprecated) {152 const deprecated = {153 'nft': nonFungibleDeprecatedAbi,154 'rft': refungibleDeprecatedAbi,155 'ft': fungibleDeprecatedAbi,156 }[mode];157 abi = [...abi, ...deprecated];158 }159 const web3 = this.helper.getWeb3();160 return new web3.eth.Contract(abi as any, address, {161 gas: this.helper.eth.DEFAULT_GAS,162 ...(caller ? {from: caller} : {}),163 });164 }165166 collectionById(collectionId: number, mode: 'nft' | 'rft' | 'ft', caller?: string, mergeDeprecated = false) {167 return this.collection(this.helper.ethAddress.fromCollectionId(collectionId), mode, caller, mergeDeprecated);168 }169170 rftToken(address: string, caller?: string, mergeDeprecated = false) {171 const web3 = this.helper.getWeb3();172 const abi = mergeDeprecated ? [...refungibleTokenAbi, ...refungibleTokenDeprecatedAbi] : refungibleTokenAbi;173 return new web3.eth.Contract(abi as any, address, {174 gas: this.helper.eth.DEFAULT_GAS,175 ...(caller ? {from: caller} : {}),176 });177 }178179 rftTokenById(collectionId: number, tokenId: number, caller?: string, mergeDeprecated = false) {180 return this.rftToken(this.helper.ethAddress.fromTokenId(collectionId, tokenId), caller, mergeDeprecated);181 }182}183184185class EthGroup extends EthGroupBase {186 DEFAULT_GAS = 2_500_000;187188 createAccount() {189 const web3 = this.helper.getWeb3();190 const account = web3.eth.accounts.create();191 web3.eth.accounts.wallet.add(account.privateKey);192 return account.address;193 }194195 async createAccountWithBalance(donor: IKeyringPair, amount = 600n) {196 const account = this.createAccount();197 await this.transferBalanceFromSubstrate(donor, account, amount);198199 return account;200 }201202 async transferBalanceFromSubstrate(donor: IKeyringPair, recepient: string, amount = 100n, inTokens = true) {203 return await this.helper.balance.transferToSubstrate(donor, evmToAddress(recepient), amount * (inTokens ? this.helper.balance.getOneTokenNominal() : 1n));204 }205206 async getCollectionCreationFee(signer: string) {207 const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);208 return await collectionHelper.methods.collectionCreationFee().call();209 }210211 async sendEVM(signer: IKeyringPair, contractAddress: string, abi: string, value: string, gasLimit?: number) {212 if(!gasLimit) gasLimit = this.DEFAULT_GAS;213 const web3 = this.helper.getWeb3();214 // FIXME: can't send legacy transaction using tx.evm.call215 const gasPrice = await web3.eth.getGasPrice();216 // TODO: check execution status217 await this.helper.executeExtrinsic(218 signer,219 'api.tx.evm.call', [this.helper.address.substrateToEth(signer.address), contractAddress, abi, value, gasLimit, gasPrice, null, null, []],220 true,221 );222 }223224 async callEVM(signer: TEthereumAccount, contractAddress: string, abi: string) {225 return await this.helper.callRpc('api.rpc.eth.call', [{from: signer, to: contractAddress, data: abi}]);226 }227228 createCollectionMethodName(mode: TCollectionMode) {229 switch (mode) {230 case 'ft':231 return 'createFTCollection';232 case 'nft':233 return 'createNFTCollection';234 case 'rft':235 return 'createRFTCollection';236 }237 }238239 async createCollection(mode: TCollectionMode, signer: string, name: string, description: string, tokenPrefix: string, decimals = 18, mergeDeprecated = false): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[], collection: Contract }> {240 const collectionCreationPrice = this.helper.balance.getCollectionCreationPrice();241 const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);242 const functionName: string = this.createCollectionMethodName(mode);243244 const functionParams = mode === 'ft' ? [name, decimals, description, tokenPrefix] : [name, description, tokenPrefix];245 const result = await collectionHelper.methods[functionName](...functionParams).send({value: Number(collectionCreationPrice)});246247 const collectionAddress = this.helper.ethAddress.normalizeAddress(result.events.CollectionCreated.returnValues.collectionId);248 const collectionId = this.helper.ethAddress.extractCollectionId(collectionAddress);249 const events = this.helper.eth.normalizeEvents(result.events);250 const collection = await this.helper.ethNativeContract.collectionById(collectionId, mode, signer, mergeDeprecated);251252 return {collectionId, collectionAddress, events, collection};253 }254255 createNFTCollection(signer: string, name: string, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {256 return this.createCollection('nft', signer, name, description, tokenPrefix);257 }258259 async createERC721MetadataCompatibleNFTCollection(signer: string, name: string, description: string, tokenPrefix: string, baseUri: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {260 const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);261262 const {collectionId, collectionAddress, events} = await this.createCollection('nft', signer, name, description, tokenPrefix);263264 await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();265266 return {collectionId, collectionAddress, events};267 }268269 createRFTCollection(signer: string, name: string, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {270 return this.createCollection('rft', signer, name, description, tokenPrefix);271 }272273 createFungibleCollection(signer: string, name: string, decimals: number, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {274 return this.createCollection('ft', signer, name, description, tokenPrefix, decimals);275 }276277 async createERC721MetadataCompatibleRFTCollection(signer: string, name: string, description: string, tokenPrefix: string, baseUri: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {278 const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);279280 const {collectionId, collectionAddress, events} = await this.createCollection('rft', signer, name, description, tokenPrefix);281282 await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();283284 return {collectionId, collectionAddress, events};285 }286287 async deployCollectorContract(signer: string): Promise<Contract> {288 return await this.helper.ethContract.deployByCode(signer, 'Collector', `289 // SPDX-License-Identifier: UNLICENSED290 pragma solidity ^0.8.6;291292 contract Collector {293 uint256 collected;294 fallback() external payable {295 giveMoney();296 }297 function giveMoney() public payable {298 collected += msg.value;299 }300 function getCollected() public view returns (uint256) {301 return collected;302 }303 function getUnaccounted() public view returns (uint256) {304 return address(this).balance - collected;305 }306307 function withdraw(address payable target) public {308 target.transfer(collected);309 collected = 0;310 }311 }312 `);313 }314315 async deployFlipper(signer: string): Promise<Contract> {316 return await this.helper.ethContract.deployByCode(signer, 'Flipper', `317 // SPDX-License-Identifier: UNLICENSED318 pragma solidity ^0.8.6;319320 contract Flipper {321 bool value = false;322 function flip() public {323 value = !value;324 }325 function getValue() public view returns (bool) {326 return value;327 }328 }329 `);330 }331332 async recordCallFee(user: string, call: () => Promise<any>): Promise<bigint> {333 const before = await this.helper.balance.getEthereum(user);334 await call();335 // In dev mode, the transaction might not finish processing in time336 await this.helper.wait.newBlocks(1);337 const after = await this.helper.balance.getEthereum(user);338339 return before - after;340 }341342 normalizeEvents(events: any): NormalizedEvent[] {343 const output = [];344 for(const key of Object.keys(events)) {345 if(key.match(/^[0-9]+$/)) {346 output.push(events[key]);347 } else if(Array.isArray(events[key])) {348 output.push(...events[key]);349 } else {350 output.push(events[key]);351 }352 }353 output.sort((a, b) => a.logIndex - b.logIndex);354 return output.map(({address, event, returnValues}) => {355 const args: { [key: string]: string } = {};356 for(const key of Object.keys(returnValues)) {357 if(!key.match(/^[0-9]+$/)) {358 args[key] = returnValues[key];359 }360 }361 return {362 address,363 event,364 args,365 };366 });367 }368369 async calculateFee(address: ICrossAccountId, code: () => Promise<any>): Promise<bigint> {370 const wrappedCode = async () => {371 await code();372 // In dev mode, the transaction might not finish processing in time373 await this.helper.wait.newBlocks(1);374 };375 return await this.helper.arrange.calculcateFee(address, wrappedCode);376 }377}378379class EthAddressGroup extends EthGroupBase {380 extractCollectionId(address: string): number {381 if(!(address.length === 42 || address.length === 40)) throw new Error('address wrong format');382 return parseInt(address.slice(address.length - 8), 16);383 }384385 fromCollectionId(collectionId: number): string {386 if(collectionId >= 0xffffffff || collectionId < 0) throw new Error('collectionId overflow');387 return Web3.utils.toChecksumAddress(`0x17c4e6453cc49aaaaeaca894e6d9683e${collectionId.toString(16).padStart(8,'0')}`);388 }389390 extractTokenId(address: string): { collectionId: number, tokenId: number } {391 if(!address.startsWith('0x'))392 throw 'address not starts with "0x"';393 if(address.length > 42)394 throw 'address length is more than 20 bytes';395 return {396 collectionId: Number('0x' + address.substring(address.length - 16, address.length - 8)),397 tokenId: Number('0x' + address.substring(address.length - 8)),398 };399 }400401 fromTokenId(collectionId: number, tokenId: number): string {402 return this.helper.util.getTokenAddress({collectionId, tokenId});403 }404405 normalizeAddress(address: string): string {406 return '0x' + address.substring(address.length - 40);407 }408}409export class EthPropertyGroup extends EthGroupBase {410 property(key: string, value: string): EthProperty {411 return [412 key,413 '0x' + Buffer.from(value).toString('hex'),414 ];415 }416}417export type EthUniqueHelperConstructor = new (...args: any[]) => EthUniqueHelper;418419export class EthCrossAccountGroup extends EthGroupBase {420 createAccount(): CrossAddress {421 return this.fromAddress(this.helper.eth.createAccount());422 }423424 async createAccountWithBalance(donor: IKeyringPair, amount = 100n) {425 return this.fromAddress(await this.helper.eth.createAccountWithBalance(donor, amount));426 }427428 fromAddress(address: TEthereumAccount): CrossAddress {429 return {430 eth: address,431 sub: '0',432 };433 }434435 fromKeyringPair(keyring: IKeyringPair): CrossAddress {436 return {437 eth: '0x0000000000000000000000000000000000000000',438 sub: keyring.addressRaw,439 };440 }441}442443export class FeeGas {444 fee: number | bigint = 0n;445446 gas: number | bigint = 0n;447448 public static async build(helper: EthUniqueHelper, fee: bigint): Promise<FeeGas> {449 const instance = new FeeGas();450 instance.fee = instance.convertToTokens(fee);451 instance.gas = await instance.convertToGas(fee, helper);452 return instance;453 }454455 private async convertToGas(fee: bigint, helper: EthUniqueHelper): Promise<bigint> {456 const gasPrice = BigInt(await helper.getWeb3().eth.getGasPrice());457 return fee / gasPrice;458 }459460 private convertToTokens(value: bigint, nominal = 1_000_000_000_000_000_000n): number {461 return Number((value * 1000n) / nominal) / 1000;462 }463}464465class EthArrangeGroup extends ArrangeGroup {466 helper: EthUniqueHelper;467468 constructor(helper: EthUniqueHelper) {469 super(helper);470 this.helper = helper;471 }472473 async calculcateFeeGas(payer: ICrossAccountId, promise: () => Promise<any>): Promise<FeeGas> {474 const fee = await this.calculcateFee(payer, promise);475 return await FeeGas.build(this.helper, fee);476 }477}478export class EthUniqueHelper extends DevUniqueHelper {479 web3: Web3 | null = null;480 web3Provider: WebsocketProvider | null = null;481482 eth: EthGroup;483 ethAddress: EthAddressGroup;484 ethCrossAccount: EthCrossAccountGroup;485 ethNativeContract: NativeContractGroup;486 ethContract: ContractGroup;487 ethProperty: EthPropertyGroup;488 arrange: EthArrangeGroup;489 constructor(logger: { log: (msg: any, level: any) => void, level: any }, options: { [key: string]: any } = {}) {490 options.helperBase = options.helperBase ?? EthUniqueHelper;491492 super(logger, options);493 this.eth = new EthGroup(this);494 this.ethAddress = new EthAddressGroup(this);495 this.ethCrossAccount = new EthCrossAccountGroup(this);496 this.ethNativeContract = new NativeContractGroup(this);497 this.ethContract = new ContractGroup(this);498 this.ethProperty = new EthPropertyGroup(this);499 this.arrange = new EthArrangeGroup(this);500 super.arrange = this.arrange;501 }502503 getWeb3(): Web3 {504 if(this.web3 === null) throw Error('Web3 not connected');505 return this.web3;506 }507508 connectWeb3(wsEndpoint: string) {509 if(this.web3 !== null) return;510 this.web3Provider = new Web3.providers.WebsocketProvider(wsEndpoint);511 this.web3 = new Web3(this.web3Provider);512 }513514 async disconnect() {515 if(this.web3 === null) return;516 this.web3Provider?.connection.close();517518 await super.disconnect();519 }520521 clearApi() {522 super.clearApi();523 this.web3 = null;524 }525526 clone(helperCls: EthUniqueHelperConstructor, options?: { [key: string]: any; }): EthUniqueHelper {527 const newHelper = super.clone(helperCls, options) as EthUniqueHelper;528 newHelper.web3 = this.web3;529 newHelper.web3Provider = this.web3Provider;530531 return newHelper;532 }533}1// Copyright 2019-2022 Unique Network (Gibraltar) Ltd.2// SPDX-License-Identifier: Apache-2.034/* eslint-disable function-call-argument-newline */5// eslint-disable-next-line @typescript-eslint/triple-slash-reference6/// <reference path="unique.dev.d.ts" />78import {readFile} from 'fs/promises';910import Web3 from 'web3';11import {WebsocketProvider} from 'web3-core';12import {Contract} from 'web3-eth-contract';1314import solc from 'solc';1516import {evmToAddress} from '@polkadot/util-crypto';17import {IKeyringPair} from '@polkadot/types/types';1819import {ArrangeGroup, DevUniqueHelper} from '../../../util/playgrounds/unique.dev';2021import {ContractImports, CompiledContract, CrossAddress, NormalizedEvent, EthProperty} from './types';2223// Native contracts ABI24import collectionHelpersAbi from '../../abi/collectionHelpers.json' assert {type: 'json'};25import nativeFungibleAbi from '../../abi/nativeFungible.json' assert {type: 'json'};26import fungibleAbi from '../../abi/fungible.json' assert {type: 'json'};27import fungibleDeprecatedAbi from '../../abi/fungibleDeprecated.json' assert {type: 'json'};28import nonFungibleAbi from '../../abi/nonFungible.json' assert {type: 'json'};29import nonFungibleDeprecatedAbi from '../../abi/nonFungibleDeprecated.json' assert {type: 'json'};30import refungibleAbi from '../../abi/reFungible.json' assert {type: 'json'};31import refungibleDeprecatedAbi from '../../abi/reFungibleDeprecated.json' assert {type: 'json'};32import refungibleTokenAbi from '../../abi/reFungibleToken.json' assert {type: 'json'};33import refungibleTokenDeprecatedAbi from '../../abi/reFungibleTokenDeprecated.json' assert {type: 'json'};34import contractHelpersAbi from '../../abi/contractHelpers.json' assert {type: 'json'};35import {ICrossAccountId, TEthereumAccount} from '../../../util/playgrounds/types';36import {TCollectionMode} from '../../../util/playgrounds/types';3738class EthGroupBase {39 helper: EthUniqueHelper;40 gasPrice?: string;4142 constructor(helper: EthUniqueHelper) {43 this.helper = helper;44 }45 async getGasPrice() {46 if(this.gasPrice)47 return this.gasPrice;48 this.gasPrice = await this.helper.getWeb3().eth.getGasPrice();49 return this.gasPrice;50 }51}525354class ContractGroup extends EthGroupBase {55 async findImports(imports?: ContractImports[]) {56 if(!imports) return function(path: string) {57 return {error: `File not found: ${path}`};58 };5960 const knownImports = {} as { [key: string]: string };61 for(const imp of imports) {62 knownImports[imp.solPath] = (await readFile(imp.fsPath)).toString();63 }6465 return function(path: string) {66 if(path in knownImports) return {contents: knownImports[path]};67 return {error: `File not found: ${path}`};68 };69 }7071 async compile(name: string, src: string, imports?: ContractImports[]): Promise<CompiledContract> {72 const compiled = JSON.parse(solc.compile(JSON.stringify({73 language: 'Solidity',74 sources: {75 [`${name}.sol`]: {76 content: src,77 },78 },79 settings: {80 outputSelection: {81 '*': {82 '*': ['*'],83 },84 },85 },86 }), {import: await this.findImports(imports)}));8788 const hasErrors = compiled['errors']89 && compiled['errors'].length > 090 && compiled.errors.some(function(err: any) {91 return err.severity == 'error';92 });9394 if(hasErrors) {95 throw compiled.errors;96 }97 const out = compiled.contracts[`${name}.sol`][name];9899 return {100 abi: out.abi,101 object: '0x' + out.evm.bytecode.object,102 };103 }104105 async deployByCode(signer: string, name: string, src: string, imports?: ContractImports[], gas?: number, args?: any[]): Promise<Contract> {106 const compiledContract = await this.compile(name, src, imports);107 return this.deployByAbi(signer, compiledContract.abi, compiledContract.object, gas, args);108 }109110 async deployByAbi(signer: string, abi: any, object: string, gas?: number, args?: any[]): Promise<Contract> {111 const web3 = this.helper.getWeb3();112 const contract = new web3.eth.Contract(abi, undefined, {113 data: object,114 from: signer,115 gas: gas ?? this.helper.eth.DEFAULT_GAS,116 });117 return await contract.deploy({data: object, arguments: args}).send({from: signer});118 }119120}121122class NativeContractGroup extends EthGroupBase {123124 contractHelpers(caller: string) {125 const web3 = this.helper.getWeb3();126 return new web3.eth.Contract(contractHelpersAbi as any, this.helper.getApi().consts.evmContractHelpers.contractAddress.toString(), {127 from: caller,128 gas: this.helper.eth.DEFAULT_GAS,129 });130 }131132 collectionHelpers(caller: string) {133 const web3 = this.helper.getWeb3();134 return new web3.eth.Contract(collectionHelpersAbi as any, this.helper.getApi().consts.common.contractAddress.toString(), {135 from: caller,136 gas: this.helper.eth.DEFAULT_GAS,137 });138 }139140 collection(address: string, mode: TCollectionMode, caller?: string, mergeDeprecated = false) {141 let abi;142 if(address === this.helper.ethAddress.fromCollectionId(0)) {143 abi = nativeFungibleAbi;144 } else {145 abi ={146 'nft': nonFungibleAbi,147 'rft': refungibleAbi,148 'ft': fungibleAbi,149 }[mode];150 }151 if(mergeDeprecated) {152 const deprecated = {153 'nft': nonFungibleDeprecatedAbi,154 'rft': refungibleDeprecatedAbi,155 'ft': fungibleDeprecatedAbi,156 }[mode];157 abi = [...abi, ...deprecated];158 }159 const web3 = this.helper.getWeb3();160 return new web3.eth.Contract(abi as any, address, {161 gas: this.helper.eth.DEFAULT_GAS,162 ...(caller ? {from: caller} : {}),163 });164 }165166 collectionById(collectionId: number, mode: 'nft' | 'rft' | 'ft', caller?: string, mergeDeprecated = false) {167 return this.collection(this.helper.ethAddress.fromCollectionId(collectionId), mode, caller, mergeDeprecated);168 }169170 rftToken(address: string, caller?: string, mergeDeprecated = false) {171 const web3 = this.helper.getWeb3();172 const abi = mergeDeprecated ? [...refungibleTokenAbi, ...refungibleTokenDeprecatedAbi] : refungibleTokenAbi;173 return new web3.eth.Contract(abi as any, address, {174 gas: this.helper.eth.DEFAULT_GAS,175 ...(caller ? {from: caller} : {}),176 });177 }178179 rftTokenById(collectionId: number, tokenId: number, caller?: string, mergeDeprecated = false) {180 return this.rftToken(this.helper.ethAddress.fromTokenId(collectionId, tokenId), caller, mergeDeprecated);181 }182}183184185class EthGroup extends EthGroupBase {186 DEFAULT_GAS = 2_500_000;187188 createAccount() {189 const web3 = this.helper.getWeb3();190 const account = web3.eth.accounts.create();191 web3.eth.accounts.wallet.add(account.privateKey);192 return account.address;193 }194195 async createAccountWithBalance(donor: IKeyringPair, amount = 600n) {196 const account = this.createAccount();197 await this.transferBalanceFromSubstrate(donor, account, amount);198199 return account;200 }201202 async transferBalanceFromSubstrate(donor: IKeyringPair, recepient: string, amount = 100n, inTokens = true) {203 return await this.helper.balance.transferToSubstrate(donor, evmToAddress(recepient), amount * (inTokens ? this.helper.balance.getOneTokenNominal() : 1n));204 }205206 async getCollectionCreationFee(signer: string) {207 const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);208 return await collectionHelper.methods.collectionCreationFee().call();209 }210211 async sendEVM(signer: IKeyringPair, contractAddress: string, abi: string, value: string, gasLimit?: number) {212 if(!gasLimit) gasLimit = this.DEFAULT_GAS;213 const web3 = this.helper.getWeb3();214 // FIXME: can't send legacy transaction using tx.evm.call215 const gasPrice = await web3.eth.getGasPrice();216 // TODO: check execution status217 await this.helper.executeExtrinsic(218 signer,219 'api.tx.evm.call', [this.helper.address.substrateToEth(signer.address), contractAddress, abi, value, gasLimit, gasPrice, null, null, []],220 true,221 );222 }223224 async callEVM(signer: TEthereumAccount, contractAddress: string, abi: string) {225 return await this.helper.callRpc('api.rpc.eth.call', [{from: signer, to: contractAddress, data: abi}]);226 }227228 createCollectionMethodName(mode: TCollectionMode) {229 switch (mode) {230 case 'ft':231 return 'createFTCollection';232 case 'nft':233 return 'createNFTCollection';234 case 'rft':235 return 'createRFTCollection';236 }237 }238239 async createCollection(mode: TCollectionMode, signer: string, name: string, description: string, tokenPrefix: string, decimals = 18, mergeDeprecated = false): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[], collection: Contract }> {240 const collectionCreationPrice = this.helper.balance.getCollectionCreationPrice();241 const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);242 const functionName: string = this.createCollectionMethodName(mode);243244 const functionParams = mode === 'ft' ? [name, decimals, description, tokenPrefix] : [name, description, tokenPrefix];245 const result = await collectionHelper.methods[functionName](...functionParams).send({value: Number(collectionCreationPrice)});246247 const collectionAddress = this.helper.ethAddress.normalizeAddress(result.events.CollectionCreated.returnValues.collectionId);248 const collectionId = this.helper.ethAddress.extractCollectionId(collectionAddress);249 const events = this.helper.eth.normalizeEvents(result.events);250 const collection = await this.helper.ethNativeContract.collectionById(collectionId, mode, signer, mergeDeprecated);251252 return {collectionId, collectionAddress, events, collection};253 }254255 createNFTCollection(signer: string, name: string, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {256 return this.createCollection('nft', signer, name, description, tokenPrefix);257 }258259 async createERC721MetadataCompatibleNFTCollection(signer: string, name: string, description: string, tokenPrefix: string, baseUri: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {260 const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);261262 const {collectionId, collectionAddress, events} = await this.createCollection('nft', signer, name, description, tokenPrefix);263264 await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();265266 return {collectionId, collectionAddress, events};267 }268269 createRFTCollection(signer: string, name: string, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {270 return this.createCollection('rft', signer, name, description, tokenPrefix);271 }272273 createFungibleCollection(signer: string, name: string, decimals: number, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {274 return this.createCollection('ft', signer, name, description, tokenPrefix, decimals);275 }276277 async createERC721MetadataCompatibleRFTCollection(signer: string, name: string, description: string, tokenPrefix: string, baseUri: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {278 const collectionHelper = await this.helper.ethNativeContract.collectionHelpers(signer);279280 const {collectionId, collectionAddress, events} = await this.createCollection('rft', signer, name, description, tokenPrefix);281282 await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();283284 return {collectionId, collectionAddress, events};285 }286287 async deployCollectorContract(signer: string): Promise<Contract> {288 return await this.helper.ethContract.deployByCode(signer, 'Collector', `289 // SPDX-License-Identifier: UNLICENSED290 pragma solidity ^0.8.6;291292 contract Collector {293 uint256 collected;294 fallback() external payable {295 giveMoney();296 }297 function giveMoney() public payable {298 collected += msg.value;299 }300 function getCollected() public view returns (uint256) {301 return collected;302 }303 function getUnaccounted() public view returns (uint256) {304 return address(this).balance - collected;305 }306307 function withdraw(address payable target) public {308 target.transfer(collected);309 collected = 0;310 }311 }312 `);313 }314315 async deployFlipper(signer: string): Promise<Contract> {316 return await this.helper.ethContract.deployByCode(signer, 'Flipper', `317 // SPDX-License-Identifier: UNLICENSED318 pragma solidity ^0.8.6;319320 contract Flipper {321 bool value = false;322 function flip() public {323 value = !value;324 }325 function getValue() public view returns (bool) {326 return value;327 }328 }329 `);330 }331332 async recordCallFee(user: string, call: () => Promise<any>): Promise<bigint> {333 const before = await this.helper.balance.getEthereum(user);334 await call();335 // In dev mode, the transaction might not finish processing in time336 await this.helper.wait.newBlocks(1);337 const after = await this.helper.balance.getEthereum(user);338339 return before - after;340 }341342 normalizeEvents(events: any): NormalizedEvent[] {343 const output = [];344 for(const key of Object.keys(events)) {345 if(key.match(/^[0-9]+$/)) {346 output.push(events[key]);347 } else if(Array.isArray(events[key])) {348 output.push(...events[key]);349 } else {350 output.push(events[key]);351 }352 }353 output.sort((a, b) => a.logIndex - b.logIndex);354 return output.map(({address, event, returnValues}) => {355 const args: { [key: string]: string } = {};356 for(const key of Object.keys(returnValues)) {357 if(!key.match(/^[0-9]+$/)) {358 args[key] = returnValues[key];359 }360 }361 return {362 address,363 event,364 args,365 };366 });367 }368369 async calculateFee(address: ICrossAccountId, code: () => Promise<any>): Promise<bigint> {370 const wrappedCode = async () => {371 await code();372 // In dev mode, the transaction might not finish processing in time373 await this.helper.wait.newBlocks(1);374 };375 return await this.helper.arrange.calculcateFee(address, wrappedCode);376 }377}378379class EthAddressGroup extends EthGroupBase {380 extractCollectionId(address: string): number {381 if(!(address.length === 42 || address.length === 40)) throw new Error('address wrong format');382 return parseInt(address.slice(address.length - 8), 16);383 }384385 fromCollectionId(collectionId: number): string {386 if(collectionId >= 0xffffffff || collectionId < 0) throw new Error('collectionId overflow');387 return Web3.utils.toChecksumAddress(`0x17c4e6453cc49aaaaeaca894e6d9683e${collectionId.toString(16).padStart(8,'0')}`);388 }389390 extractTokenId(address: string): { collectionId: number, tokenId: number } {391 if(!address.startsWith('0x'))392 throw 'address not starts with "0x"';393 if(address.length > 42)394 throw 'address length is more than 20 bytes';395 return {396 collectionId: Number('0x' + address.substring(address.length - 16, address.length - 8)),397 tokenId: Number('0x' + address.substring(address.length - 8)),398 };399 }400401 fromTokenId(collectionId: number, tokenId: number): string {402 return this.helper.util.getTokenAddress({collectionId, tokenId});403 }404405 normalizeAddress(address: string): string {406 return '0x' + address.substring(address.length - 40);407 }408}409export class EthPropertyGroup extends EthGroupBase {410 property(key: string, value: string): EthProperty {411 return [412 key,413 '0x' + Buffer.from(value).toString('hex'),414 ];415 }416}417export type EthUniqueHelperConstructor = new (...args: any[]) => EthUniqueHelper;418419export class EthCrossAccountGroup extends EthGroupBase {420 createAccount(): CrossAddress {421 return this.fromAddress(this.helper.eth.createAccount());422 }423424 async createAccountWithBalance(donor: IKeyringPair, amount = 100n) {425 return this.fromAddress(await this.helper.eth.createAccountWithBalance(donor, amount));426 }427428 fromAddress(address: TEthereumAccount): CrossAddress {429 return {430 eth: address,431 sub: '0',432 };433 }434435 fromKeyringPair(keyring: IKeyringPair): CrossAddress {436 return {437 eth: '0x0000000000000000000000000000000000000000',438 sub: keyring.addressRaw,439 };440 }441}442443export class FeeGas {444 fee: number | bigint = 0n;445446 gas: number | bigint = 0n;447448 public static async build(helper: EthUniqueHelper, fee: bigint): Promise<FeeGas> {449 const instance = new FeeGas();450 instance.fee = instance.convertToTokens(fee);451 instance.gas = await instance.convertToGas(fee, helper);452 return instance;453 }454455 private async convertToGas(fee: bigint, helper: EthUniqueHelper): Promise<bigint> {456 const gasPrice = BigInt(await helper.getWeb3().eth.getGasPrice());457 return fee / gasPrice;458 }459460 private convertToTokens(value: bigint, nominal = 1_000_000_000_000_000_000n): number {461 return Number((value * 1000n) / nominal) / 1000;462 }463}464465class EthArrangeGroup extends ArrangeGroup {466 helper: EthUniqueHelper;467468 constructor(helper: EthUniqueHelper) {469 super(helper);470 this.helper = helper;471 }472473 async calculcateFeeGas(payer: ICrossAccountId, promise: () => Promise<any>): Promise<FeeGas> {474 const fee = await this.calculcateFee(payer, promise);475 return await FeeGas.build(this.helper, fee);476 }477}478export class EthUniqueHelper extends DevUniqueHelper {479 web3: Web3 | null = null;480 web3Provider: WebsocketProvider | null = null;481482 eth: EthGroup;483 ethAddress: EthAddressGroup;484 ethCrossAccount: EthCrossAccountGroup;485 ethNativeContract: NativeContractGroup;486 ethContract: ContractGroup;487 ethProperty: EthPropertyGroup;488 arrange: EthArrangeGroup;489 constructor(logger: { log: (msg: any, level: any) => void, level: any }, options: { [key: string]: any } = {}) {490 options.helperBase = options.helperBase ?? EthUniqueHelper;491492 super(logger, options);493 this.eth = new EthGroup(this);494 this.ethAddress = new EthAddressGroup(this);495 this.ethCrossAccount = new EthCrossAccountGroup(this);496 this.ethNativeContract = new NativeContractGroup(this);497 this.ethContract = new ContractGroup(this);498 this.ethProperty = new EthPropertyGroup(this);499 this.arrange = new EthArrangeGroup(this);500 super.arrange = this.arrange;501 }502503 getWeb3(): Web3 {504 if(this.web3 === null) throw Error('Web3 not connected');505 return this.web3;506 }507508 connectWeb3(wsEndpoint: string) {509 if(this.web3 !== null) return;510 this.web3Provider = new Web3.providers.WebsocketProvider(wsEndpoint);511 this.web3 = new Web3(this.web3Provider);512 }513514 async disconnect() {515 if(this.web3 === null) return;516 this.web3Provider?.connection.close();517518 await super.disconnect();519 }520521 clearApi() {522 super.clearApi();523 this.web3 = null;524 }525526 clone(helperCls: EthUniqueHelperConstructor, options?: { [key: string]: any; }): EthUniqueHelper {527 const newHelper = super.clone(helperCls, options) as EthUniqueHelper;528 newHelper.web3 = this.web3;529 newHelper.web3Provider = this.web3Provider;530531 return newHelper;532 }533}