12345678import {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';222324import 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 215 const gasPrice = await web3.eth.getGasPrice();216 217 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 336 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 373 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}