From f715967133c1756b3231d4fb3cceebb3e8305f3a Mon Sep 17 00:00:00 2001 From: Yaroslav Bolyukin Date: Mon, 14 Nov 2022 11:16:16 +0000 Subject: [PATCH] Merge pull request #714 from UniqueNetwork/research/tokenMintFee Research/token mint fee --- --- a/tests/.gitignore +++ b/tests/.gitignore @@ -1 +1,2 @@ /node_modules/ +properties.csv --- a/tests/package.json +++ b/tests/package.json @@ -101,6 +101,7 @@ "testXcmTransferAcala": "mocha --timeout 9999999 -r ts-node/register ./**/xcm/xcmTransferAcala.test.ts acalaId=2000 uniqueId=5000", "testXcmTransferStatemine": "mocha --timeout 9999999 -r ts-node/register ./**/xcm/xcmTransferStatemine.test.ts statemineId=1000 uniqueId=5000", "testXcmTransferMoonbeam": "mocha --timeout 9999999 -r ts-node/register ./**/xcm/xcmTransferMoonbeam.test.ts", + "benchMintingFee": "ts-node src/benchmarks/mintFee/benchmark.ts", "load": "mocha --timeout 9999999 -r ts-node/register './**/*.load.ts'", "loadTransfer": "ts-node src/transfer.nload.ts", "polkadot-types-fetch-metadata": "curl -H 'Content-Type: application/json' -d '{\"id\":\"1\", \"jsonrpc\":\"2.0\", \"method\": \"state_getMetadata\", \"params\":[]}' http://localhost:9933 > src/interfaces/metadata.json", @@ -116,6 +117,7 @@ "@polkadot/util-crypto": "10.1.11", "chai-as-promised": "^7.1.1", "chai-like": "^1.1.1", + "csv-writer": "^1.6.0", "find-process": "^1.4.7", "solc": "0.8.17", "web3": "^1.8.0" --- /dev/null +++ b/tests/src/benchmarks/mintFee/benchmark.ts @@ -0,0 +1,436 @@ +import {EthUniqueHelper, usingEthPlaygrounds} from '../../eth/util'; +import {readFile} from 'fs/promises'; +import {ContractImports} from '../../eth/util/playgrounds/types'; +import { + ICrossAccountId, + ITokenPropertyPermission, +} from '../../util/playgrounds/types'; +import {IKeyringPair} from '@polkadot/types/types'; +import {UniqueNFTCollection} from '../../util/playgrounds/unique'; +import {Contract} from 'web3-eth-contract'; +import {createObjectCsvWriter} from 'csv-writer'; + +const CONTRACT_IMPORT: ContractImports[] = [ + { + fsPath: `${__dirname}/../../eth/api/CollectionHelpers.sol`, + solPath: 'eth/api/CollectionHelpers.sol', + }, + { + fsPath: `${__dirname}/../../eth/api/ContractHelpers.sol`, + solPath: 'eth/api/ContractHelpers.sol', + }, + { + fsPath: `${__dirname}/../../eth/api/UniqueRefungibleToken.sol`, + solPath: 'eth/api/UniqueRefungibleToken.sol', + }, + { + fsPath: `${__dirname}/../../eth/api/UniqueRefungible.sol`, + solPath: 'eth/api/UniqueRefungible.sol', + }, + { + fsPath: `${__dirname}/../../eth/api/UniqueNFT.sol`, + solPath: 'eth/api/UniqueNFT.sol', + }, +]; + +const PROPERTIES = Array(40) + .fill(0) + .map((_, i) => { + return { + key: `key_${i}`, + value: Uint8Array.from(Buffer.from(`value_${i}`)), + }; + }); + +const PERMISSIONS: ITokenPropertyPermission[] = PROPERTIES.map((p) => { + return { + key: p.key, + permission: { + tokenOwner: true, + collectionAdmin: true, + mutable: true, + }, + }; +}); + +interface IBenchmarkResultForProp { + propertiesNumber: number; + substrateFee: number; + ethFee: number; + ethBulkFee: number; + evmProxyContractFee: number; + evmProxyContractBulkFee: number; +} + +const main = async () => { + const benchmarks = [ + 'substrateFee', + 'ethFee', + 'ethBulkFee', + 'evmProxyContractFee', + 'evmProxyContractBulkFee', + ]; + const headers = [ + 'propertiesNumber', + ...benchmarks, + ]; + + + const csvWriter = createObjectCsvWriter({ + path: 'properties.csv', + header: headers, + }); + + await usingEthPlaygrounds(async (helper, privateKey) => { + const CONTRACT_SOURCE = ( + await readFile(`${__dirname}/proxyContract.sol`) + ).toString(); + + const donor = await privateKey('//Alice'); // Seed from account with balance on this network + const ethSigner = await helper.eth.createAccountWithBalance(donor, 100n); + + const contract = await helper.ethContract.deployByCode( + ethSigner, + 'ProxyMint', + CONTRACT_SOURCE, + CONTRACT_IMPORT, + ); + + const fees = await benchMintFee(helper, privateKey, contract); + console.log('Minting without properties'); + console.table(fees); + + const result: IBenchmarkResultForProp[] = []; + const csvResult: IBenchmarkResultForProp[] = []; + + for (let i = 1; i <= 20; i++) { + const benchResult = await benchMintWithProperties(helper, privateKey, contract, { + propertiesNumber: i, + }) as any; + + csvResult.push(benchResult); + + const minFee = Math.min(...(benchmarks.map(x => benchResult[x]))); + for(const key of benchmarks) { + const keyPercent = Math.round((benchResult[key] / minFee) * 100); + benchResult[key] = `${benchResult[key]} (${keyPercent}%)`; + } + + result.push(benchResult); + } + + await csvWriter.writeRecords(csvResult); + + console.log('Minting with properties'); + console.table(result, headers); + }); +}; + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.log(e); + process.exit(1); + }); + +async function createCollectionForBenchmarks( + helper: EthUniqueHelper, + privateKey: (seed: string) => Promise, + ethSigner: string, + proxyContract: string, + permissions: ITokenPropertyPermission[], +) { + const donor = await privateKey('//Alice'); + + const collection = await helper.nft.mintCollection(donor, { + name: 'test mintToSubstrate', + description: 'EVMHelpers', + tokenPrefix: 'ap', + tokenPropertyPermissions: [ + { + key: 'url', + permission: { + tokenOwner: true, + collectionAdmin: true, + mutable: true, + }, + }, + ], + limits: {sponsorTransferTimeout: 0, sponsorApproveTimeout: 0}, + permissions: {mintMode: true}, + }); + + await collection.addToAllowList(donor, { + Ethereum: helper.address.substrateToEth(donor.address), + }); + await collection.addToAllowList(donor, {Substrate: donor.address}); + await collection.addAdmin(donor, {Ethereum: ethSigner}); + await collection.addAdmin(donor, { + Ethereum: helper.address.substrateToEth(donor.address), + }); + await collection.addToAllowList(donor, {Ethereum: proxyContract}); + await collection.addAdmin(donor, {Ethereum: proxyContract}); + await collection.setTokenPropertyPermissions(donor, permissions); + + return collection; +} + +async function benchMintFee( + helper: EthUniqueHelper, + privateKey: (seed: string) => Promise, + proxyContract: Contract, +): Promise<{ + substrateFee: number; + ethFee: number; + evmProxyContractFee: number; +}> { + const donor = await privateKey('//Alice'); + const substrateReceiver = await privateKey('//Bob'); + const ethSigner = await helper.eth.createAccountWithBalance(donor, 100n); + + const nominal = helper.balance.getOneTokenNominal(); + + await helper.eth.transferBalanceFromSubstrate( + donor, + proxyContract.options.address, + 100n, + ); + + const collection = await createCollectionForBenchmarks( + helper, + privateKey, + ethSigner, + proxyContract.options.address, + PERMISSIONS, + ); + + const substrateFee = await helper.arrange.calculcateFee( + {Substrate: donor.address}, + () => collection.mintToken(donor, {Substrate: substrateReceiver.address}), + ); + + const collectionEthAddress = helper.ethAddress.fromCollectionId(collection.collectionId); + const collectionContract = helper.ethNativeContract.collection( + collectionEthAddress, + 'nft', + ); + + const receiverEthAddress = helper.address.substrateToEth(substrateReceiver.address); + + const encodedCall = collectionContract.methods + .mint(receiverEthAddress) + .encodeABI(); + + const ethFee = await helper.arrange.calculcateFee( + {Substrate: donor.address}, + async () => { + await helper.eth.sendEVM( + donor, + collectionContract.options.address, + encodedCall, + '0', + ); + }, + ); + + const evmProxyContractFee = await helper.arrange.calculcateFee( + {Ethereum: ethSigner}, + async () => { + await proxyContract.methods + .mintToSubstrate( + helper.ethAddress.fromCollectionId(collection.collectionId), + substrateReceiver.addressRaw, + ) + .send({from: ethSigner}); + }, + ); + + return { + substrateFee: convertToTokens(substrateFee, nominal), + ethFee: convertToTokens(ethFee, nominal), + evmProxyContractFee: convertToTokens(evmProxyContractFee, nominal), + }; +} + +async function benchMintWithProperties( + helper: EthUniqueHelper, + privateKey: (seed: string) => Promise, + proxyContract: Contract, + setup: { propertiesNumber: number }, +): Promise { + const donor = await privateKey('//Alice'); // Seed from account with balance on this network + const ethSigner = await helper.eth.createAccountWithBalance(donor, 100n); + + const susbstrateReceiver = await privateKey('//Bob'); + const receiverEthAddress = helper.address.substrateToEth(susbstrateReceiver.address); + + const nominal = helper.balance.getOneTokenNominal(); + + const substrateFee = await calculateFeeNftMintWithProperties( + helper, + privateKey, + {Substrate: donor.address}, + ethSigner, + proxyContract.options.address, + async (collection) => { + await collection.mintToken( + donor, + {Substrate: susbstrateReceiver.address}, + PROPERTIES.slice(0, setup.propertiesNumber).map((p) => { + return {key: p.key, value: Buffer.from(p.value).toString()}; + }), + ); + }, + ); + + const ethFee = await calculateFeeNftMintWithProperties( + helper, + privateKey, + {Substrate: donor.address}, + ethSigner, + proxyContract.options.address, + async (collection) => { + const evmContract = helper.ethNativeContract.collection( + helper.ethAddress.fromCollectionId(collection.collectionId), + 'nft', + ); + + const subTokenId = await evmContract.methods.nextTokenId().call(); + + let encodedCall = evmContract.methods + .mint(receiverEthAddress) + .encodeABI(); + + await helper.eth.sendEVM( + donor, + evmContract.options.address, + encodedCall, + '0', + ); + + for (const val of PROPERTIES.slice(0, setup.propertiesNumber)) { + encodedCall = await evmContract.methods + .setProperty(subTokenId, val.key, Buffer.from(val.value)) + .encodeABI(); + + await helper.eth.sendEVM( + donor, + evmContract.options.address, + encodedCall, + '0', + ); + } + }, + ); + + const ethBulkFee = await calculateFeeNftMintWithProperties( + helper, + privateKey, + {Substrate: donor.address}, + ethSigner, + proxyContract.options.address, + async (collection) => { + const evmContract = helper.ethNativeContract.collection( + helper.ethAddress.fromCollectionId(collection.collectionId), + 'nft', + ); + + const subTokenId = await evmContract.methods.nextTokenId().call(); + + let encodedCall = evmContract.methods + .mint(receiverEthAddress) + .encodeABI(); + + await helper.eth.sendEVM( + donor, + evmContract.options.address, + encodedCall, + '0', + ); + + encodedCall = await evmContract.methods + .setProperties( + subTokenId, + PROPERTIES.slice(0, setup.propertiesNumber).map((p) => { + return {field_0: p.key, field_1: p.value}; + }), + ) + .encodeABI(); + + await helper.eth.sendEVM( + donor, + evmContract.options.address, + encodedCall, + '0', + ); + }, + ); + + const proxyContractFee = await calculateFeeNftMintWithProperties( + helper, + privateKey, + {Ethereum: ethSigner}, + ethSigner, + proxyContract.options.address, + async (collection) => { + await proxyContract.methods + .mintToSubstrateWithProperty( + helper.ethAddress.fromCollectionId(collection.collectionId), + susbstrateReceiver.addressRaw, + PROPERTIES.slice(0, setup.propertiesNumber), + ) + .send({from: ethSigner}); + }, + ); + + const proxyContractBulkFee = await calculateFeeNftMintWithProperties( + helper, + privateKey, + {Ethereum: ethSigner}, + ethSigner, + proxyContract.options.address, + async (collection) => { + await proxyContract.methods + .mintToSubstrateBulkProperty( + helper.ethAddress.fromCollectionId(collection.collectionId), + susbstrateReceiver.addressRaw, + PROPERTIES.slice(0, setup.propertiesNumber).map((p) => { + return {field_0: p.key, field_1: p.value}; + }), + ) + .send({from: ethSigner, gas: 25_000_000}); + }, + ); + + return { + propertiesNumber: setup.propertiesNumber, + substrateFee: convertToTokens(substrateFee, nominal), + ethFee: convertToTokens(ethFee, nominal), + ethBulkFee: convertToTokens(ethBulkFee, nominal), + evmProxyContractFee: convertToTokens(proxyContractFee, nominal), + evmProxyContractBulkFee: convertToTokens(proxyContractBulkFee, nominal), + }; +} + +async function calculateFeeNftMintWithProperties( + helper: EthUniqueHelper, + privateKey: (seed: string) => Promise, + payer: ICrossAccountId, + ethSigner: string, + proxyContractAddress: string, + calculatedCall: (collection: UniqueNFTCollection) => Promise, +): Promise { + const collection = await createCollectionForBenchmarks( + helper, + privateKey, + ethSigner, + proxyContractAddress, + PERMISSIONS, + ); + return helper.arrange.calculcateFee(payer, async () => { + await calculatedCall(collection); + }); +} +function convertToTokens(value: bigint, nominal: bigint): number { + return Number((value * 1000n) / nominal) / 1000; +} --- /dev/null +++ b/tests/src/benchmarks/mintFee/proxyContract.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache License +pragma solidity >=0.8.0; +import {CollectionHelpers} from "../../eth/api/CollectionHelpers.sol"; +import {ContractHelpers} from "../../eth/api/ContractHelpers.sol"; +import {UniqueRefungibleToken} from "../../eth/api/UniqueRefungibleToken.sol"; +import {UniqueRefungible, Collection, EthCrossAccount as RftCrossAccountId, Tuple20 as RftProperties} from "../../eth/api/UniqueRefungible.sol"; +import {UniqueNFT, EthCrossAccount as NftCrossAccountId, Tuple21 as NftProperty, TokenProperties} from "../../eth/api/UniqueNFT.sol"; + +struct Property { + string key; + bytes value; +} + +contract ProxyMint { + bytes32 constant REFUNGIBLE_COLLECTION_TYPE = keccak256(bytes("ReFungible")); + bytes32 constant NONFUNGIBLE_COLLECTION_TYPE = keccak256(bytes("NFT")); + + modifier checkRestrictions(address _collection) { + Collection commonContract = Collection(_collection); + require(commonContract.isOwnerOrAdmin(msg.sender), "Only collection admin/owner can call this method"); + _; + } + + /// @dev This emits when a mint to a substrate address has been made. + event MintToSub(address _toEth, uint256 _toSub, address _collection, uint256 _tokenId); + + function mintToSubstrate(address _collection, uint256 _substrateReceiver) external checkRestrictions(_collection) { + Collection commonContract = Collection(_collection); + bytes32 collectionType = keccak256(bytes(commonContract.uniqueCollectionType())); + uint256 tokenId; + + if (collectionType == REFUNGIBLE_COLLECTION_TYPE) { + UniqueRefungible rftCollection = UniqueRefungible(_collection); + + tokenId = rftCollection.mint(address(this)); + + rftCollection.transferFromCross( + RftCrossAccountId(address(this), 0), + RftCrossAccountId(address(0), _substrateReceiver), + tokenId + ); + } else if (collectionType == NONFUNGIBLE_COLLECTION_TYPE) { + UniqueNFT nftCollection = UniqueNFT(_collection); + tokenId = nftCollection.mint(address(this)); + + nftCollection.transferFromCross( + NftCrossAccountId(address(this), 0), + NftCrossAccountId(address(0), _substrateReceiver), + tokenId + ); + } else { + revert("Wrong collection type. Works only with NFT or RFT"); + } + + emit MintToSub(address(0), _substrateReceiver, _collection, tokenId); + } + + function mintToSubstrateWithProperty( + address _collection, + uint256 _substrateReceiver, + Property[] calldata properties + ) external checkRestrictions(_collection) { + uint256 propertiesLength = properties.length; + require(propertiesLength > 0, "Properies is empty"); + + Collection commonContract = Collection(_collection); + bytes32 collectionType = keccak256(bytes(commonContract.uniqueCollectionType())); + uint256 tokenId; + + if (collectionType == REFUNGIBLE_COLLECTION_TYPE) { + UniqueRefungible rftCollection = UniqueRefungible(_collection); + tokenId = rftCollection.nextTokenId(); + rftCollection.mint(address(this)); + for (uint256 i = 0; i < propertiesLength; ++i) { + rftCollection.setProperty(tokenId, properties[i].key, properties[i].value); + } + rftCollection.transferFromCross( + RftCrossAccountId(address(this), 0), + RftCrossAccountId(address(0), _substrateReceiver), + tokenId + ); + } else if (collectionType == NONFUNGIBLE_COLLECTION_TYPE) { + UniqueNFT nftCollection = UniqueNFT(_collection); + tokenId = nftCollection.mint(address(this)); + for (uint256 i = 0; i < propertiesLength; ++i) { + nftCollection.setProperty(tokenId, properties[i].key, properties[i].value); + } + nftCollection.transferFromCross( + NftCrossAccountId(address(this), 0), + NftCrossAccountId(address(0), _substrateReceiver), + tokenId + ); + } else { + revert("Wrong collection type. Works only with NFT or RFT"); + } + + emit MintToSub(address(0), _substrateReceiver, _collection, tokenId); + } + + function mintToSubstrateBulkProperty( + address _collection, + uint256 _substrateReceiver, + NftProperty[] calldata _properties + ) external checkRestrictions(_collection) { + uint256 propertiesLength = _properties.length; + require(propertiesLength > 0, "Properies is empty"); + + Collection commonContract = Collection(_collection); + bytes32 collectionType = keccak256(bytes(commonContract.uniqueCollectionType())); + uint256 tokenId; + + if (collectionType == REFUNGIBLE_COLLECTION_TYPE) {} else if (collectionType == NONFUNGIBLE_COLLECTION_TYPE) { + UniqueNFT nftCollection = UniqueNFT(_collection); + tokenId = nftCollection.mint(address(this)); + + nftCollection.setProperties(tokenId, _properties); + + nftCollection.transferFromCross( + NftCrossAccountId(address(this), 0), + NftCrossAccountId(address(0), _substrateReceiver), + tokenId + ); + } else { + revert("Wrong collection type. Works only with NFT or RFT"); + } + + emit MintToSub(address(0), _substrateReceiver, _collection, tokenId); + } +} --- a/tests/yarn.lock +++ b/tests/yarn.lock @@ -1852,6 +1852,11 @@ randombytes "^2.0.0" randomfill "^1.0.3" +csv-writer@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/csv-writer/-/csv-writer-1.6.0.tgz#d0cea44b6b4d7d3baa2ecc6f3f7209233514bcf9" + integrity sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g== + d@1, d@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" -- gitstuff