12345678import {readFile} from 'fs/promises';910import Web3 from 'web3';11import {WebsocketProvider} from 'web3-core';12import {Contract} from 'web3-eth-contract';1314import * as solc from 'solc';1516import {evmToAddress} from '@polkadot/util-crypto';17import {IKeyringPair} from '@polkadot/types/types';1819import {DevUniqueHelper} from '../../../util/playgrounds/unique.dev';2021import {ContractImports, CompiledContract, TEthCrossAccount, NormalizedEvent, EthProperty} from './types';222324import collectionHelpersAbi from '../../abi/collectionHelpers.json';25import fungibleAbi from '../../abi/fungible.json';26import fungibleDeprecatedAbi from '../../abi/fungibleDeprecated.json';27import nonFungibleAbi from '../../abi/nonFungible.json';28import nonFungibleDeprecatedAbi from '../../abi/nonFungibleDeprecated.json';29import refungibleAbi from '../../abi/reFungible.json';30import refungibleDeprecatedAbi from '../../abi/reFungibleDeprecated.json';31import refungibleTokenAbi from '../../abi/reFungibleToken.json';32import contractHelpersAbi from '../../abi/contractHelpers.json';33import {ICrossAccountId, TEthereumAccount} from '../../../util/playgrounds/types';34import {TCollectionMode} from '../../../util/playgrounds/types';3536class EthGroupBase {37 helper: EthUniqueHelper;3839 constructor(helper: EthUniqueHelper) {40 this.helper = helper;41 }42}434445class ContractGroup extends EthGroupBase {46 async findImports(imports?: ContractImports[]){47 if(!imports) return function(path: string) {48 return {error: `File not found: ${path}`};49 };5051 const knownImports = {} as {[key: string]: string};52 for(const imp of imports) {53 knownImports[imp.solPath] = (await readFile(imp.fsPath)).toString();54 }5556 return function(path: string) {57 if(path in knownImports) return {contents: knownImports[path]};58 return {error: `File not found: ${path}`};59 };60 }6162 async compile(name: string, src: string, imports?: ContractImports[]): Promise<CompiledContract> {63 const compiled = JSON.parse(solc.compile(JSON.stringify({64 language: 'Solidity',65 sources: {66 [`${name}.sol`]: {67 content: src,68 },69 },70 settings: {71 outputSelection: {72 '*': {73 '*': ['*'],74 },75 },76 },77 }), {import: await this.findImports(imports)}));7879 const hasErrors = compiled['errors']80 && compiled['errors'].length > 081 && compiled.errors.some(function(err: any) {82 return err.severity == 'error';83 });84 85 if (hasErrors) {86 throw compiled.errors;87 }88 const out = compiled.contracts[`${name}.sol`][name];8990 return {91 abi: out.abi,92 object: '0x' + out.evm.bytecode.object,93 };94 }9596 async deployByCode(signer: string, name: string, src: string, imports?: ContractImports[], gas?: number): Promise<Contract> {97 const compiledContract = await this.compile(name, src, imports);98 return this.deployByAbi(signer, compiledContract.abi, compiledContract.object, gas);99 }100101 async deployByAbi(signer: string, abi: any, object: string, gas?: number): Promise<Contract> {102 const web3 = this.helper.getWeb3();103 const contract = new web3.eth.Contract(abi, undefined, {104 data: object,105 from: signer,106 gas: gas ?? this.helper.eth.DEFAULT_GAS,107 });108 return await contract.deploy({data: object}).send({from: signer});109 }110111}112113class NativeContractGroup extends EthGroupBase {114115 contractHelpers(caller: string): Contract {116 const web3 = this.helper.getWeb3();117 return new web3.eth.Contract(contractHelpersAbi as any, this.helper.getApi().consts.evmContractHelpers.contractAddress.toString(), {from: caller, gas: this.helper.eth.DEFAULT_GAS});118 }119120 collectionHelpers(caller: string) {121 const web3 = this.helper.getWeb3();122 return new web3.eth.Contract(collectionHelpersAbi as any, this.helper.getApi().consts.common.contractAddress.toString(), {from: caller, gas: this.helper.eth.DEFAULT_GAS});123 }124125 collection(address: string, mode: TCollectionMode, caller?: string, mergeDeprecated = false): Contract {126 let abi = {127 'nft': nonFungibleAbi,128 'rft': refungibleAbi,129 'ft': fungibleAbi,130 }[mode];131 if (mergeDeprecated) {132 const deprecated = {133 'nft': nonFungibleDeprecatedAbi,134 'rft': refungibleDeprecatedAbi,135 'ft': fungibleDeprecatedAbi,136 }[mode];137 abi = [...abi,...deprecated];138 }139 const web3 = this.helper.getWeb3();140 return new web3.eth.Contract(abi as any, address, {gas: this.helper.eth.DEFAULT_GAS, ...(caller ? {from: caller} : {})});141 }142143 collectionById(collectionId: number, mode: 'nft' | 'rft' | 'ft', caller?: string, mergeDeprecated = false): Contract {144 return this.collection(this.helper.ethAddress.fromCollectionId(collectionId), mode, caller, mergeDeprecated);145 }146147 rftToken(address: string, caller?: string): Contract {148 const web3 = this.helper.getWeb3();149 return new web3.eth.Contract(refungibleTokenAbi as any, address, {gas: this.helper.eth.DEFAULT_GAS, ...(caller ? {from: caller} : {})});150 }151152 rftTokenById(collectionId: number, tokenId: number, caller?: string): Contract {153 return this.rftToken(this.helper.ethAddress.fromTokenId(collectionId, tokenId), caller);154 }155}156157158class EthGroup extends EthGroupBase {159 DEFAULT_GAS = 2_500_000;160161 createAccount() {162 const web3 = this.helper.getWeb3();163 const account = web3.eth.accounts.create();164 web3.eth.accounts.wallet.add(account.privateKey);165 return account.address;166 }167168 async createAccountWithBalance(donor: IKeyringPair, amount=100n) {169 const account = this.createAccount();170 await this.transferBalanceFromSubstrate(donor, account, amount);171172 return account;173 }174175 async transferBalanceFromSubstrate(donor: IKeyringPair, recepient: string, amount=100n, inTokens=true) {176 return await this.helper.balance.transferToSubstrate(donor, evmToAddress(recepient), amount * (inTokens ? this.helper.balance.getOneTokenNominal() : 1n));177 }178179 async getCollectionCreationFee(signer: string) {180 const collectionHelper = this.helper.ethNativeContract.collectionHelpers(signer);181 return await collectionHelper.methods.collectionCreationFee().call();182 }183184 async sendEVM(signer: IKeyringPair, contractAddress: string, abi: string, value: string, gasLimit?: number) {185 if(!gasLimit) gasLimit = this.DEFAULT_GAS;186 const web3 = this.helper.getWeb3();187 const gasPrice = await web3.eth.getGasPrice();188 189 await this.helper.executeExtrinsic(190 signer,191 'api.tx.evm.call', [this.helper.address.substrateToEth(signer.address), contractAddress, abi, value, gasLimit, gasPrice, null, null, []],192 true,193 );194 }195196 async callEVM(signer: TEthereumAccount, contractAddress: string, abi: string) {197 return await this.helper.callRpc('api.rpc.eth.call', [{from: signer, to: contractAddress, data: abi}]);198 }199200 createCollectionMethodName(mode: TCollectionMode) {201 switch (mode) {202 case 'ft':203 return 'createFTCollection';204 case 'nft':205 return 'createNFTCollection';206 case 'rft':207 return 'createRFTCollection';208 }209 }210211 async createCollection(mode: TCollectionMode, signer: string, name: string, description: string, tokenPrefix: string, decimals = 18): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {212 const collectionCreationPrice = this.helper.balance.getCollectionCreationPrice();213 const collectionHelper = this.helper.ethNativeContract.collectionHelpers(signer);214 const functionName: string = this.createCollectionMethodName(mode);215216 const functionParams = mode === 'ft' ? [name, decimals, description, tokenPrefix] : [name, description, tokenPrefix];217 const result = await collectionHelper.methods[functionName](...functionParams).send({value: Number(collectionCreationPrice)});218219 const collectionAddress = this.helper.ethAddress.normalizeAddress(result.events.CollectionCreated.returnValues.collectionId);220 const collectionId = this.helper.ethAddress.extractCollectionId(collectionAddress);221 const events = this.helper.eth.normalizeEvents(result.events);222223 return {collectionId, collectionAddress, events};224 }225226 createNFTCollection(signer: string, name: string, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {227 return this.createCollection('nft', signer, name, description, tokenPrefix);228 }229230 async createERC721MetadataCompatibleNFTCollection(signer: string, name: string, description: string, tokenPrefix: string, baseUri: string): Promise<{collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {231 const collectionHelper = this.helper.ethNativeContract.collectionHelpers(signer);232233 const {collectionId, collectionAddress, events} = await this.createCollection('nft', signer, name, description, tokenPrefix);234235 await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();236237 return {collectionId, collectionAddress, events};238 }239240 createRFTCollection(signer: string, name: string, description: string, tokenPrefix: string): Promise<{collectionId: number, collectionAddress: string, events: NormalizedEvent[]}> {241 return this.createCollection('rft', signer, name, description, tokenPrefix);242 }243244 createFungibleCollection(signer: string, name: string, decimals: number, description: string, tokenPrefix: string): Promise<{ collectionId: number, collectionAddress: string, events: NormalizedEvent[]}> {245 return this.createCollection('ft', signer, name, description, tokenPrefix, decimals);246 }247248 async createERC721MetadataCompatibleRFTCollection(signer: string, name: string, description: string, tokenPrefix: string, baseUri: string): Promise<{collectionId: number, collectionAddress: string, events: NormalizedEvent[] }> {249 const collectionHelper = this.helper.ethNativeContract.collectionHelpers(signer);250251 const {collectionId, collectionAddress, events} = await this.createCollection('rft', signer, name, description, tokenPrefix);252253 await collectionHelper.methods.makeCollectionERC721MetadataCompatible(collectionAddress, baseUri).send();254255 return {collectionId, collectionAddress, events};256 }257258 async deployCollectorContract(signer: string): Promise<Contract> {259 return await this.helper.ethContract.deployByCode(signer, 'Collector', `260 // SPDX-License-Identifier: UNLICENSED261 pragma solidity ^0.8.6;262263 contract Collector {264 uint256 collected;265 fallback() external payable {266 giveMoney();267 }268 function giveMoney() public payable {269 collected += msg.value;270 }271 function getCollected() public view returns (uint256) {272 return collected;273 }274 function getUnaccounted() public view returns (uint256) {275 return address(this).balance - collected;276 }277278 function withdraw(address payable target) public {279 target.transfer(collected);280 collected = 0;281 }282 }283 `);284 }285286 async deployFlipper(signer: string): Promise<Contract> {287 return await this.helper.ethContract.deployByCode(signer, 'Flipper', `288 // SPDX-License-Identifier: UNLICENSED289 pragma solidity ^0.8.6;290291 contract Flipper {292 bool value = false;293 function flip() public {294 value = !value;295 }296 function getValue() public view returns (bool) {297 return value;298 }299 }300 `);301 }302303 async recordCallFee(user: string, call: () => Promise<any>): Promise<bigint> {304 const before = await this.helper.balance.getEthereum(user);305 await call();306 307 await this.helper.wait.newBlocks(1);308 const after = await this.helper.balance.getEthereum(user);309310 return before - after;311 }312313 normalizeEvents(events: any): NormalizedEvent[] {314 const output = [];315 for (const key of Object.keys(events)) {316 if (key.match(/^[0-9]+$/)) {317 output.push(events[key]);318 } else if (Array.isArray(events[key])) {319 output.push(...events[key]);320 } else {321 output.push(events[key]);322 }323 }324 output.sort((a, b) => a.logIndex - b.logIndex);325 return output.map(({address, event, returnValues}) => {326 const args: { [key: string]: string } = {};327 for (const key of Object.keys(returnValues)) {328 if (!key.match(/^[0-9]+$/)) {329 args[key] = returnValues[key];330 }331 }332 return {333 address,334 event,335 args,336 };337 });338 }339340 async calculateFee(address: ICrossAccountId, code: () => Promise<any>): Promise<bigint> {341 const wrappedCode = async () => {342 await code();343 344 await this.helper.wait.newBlocks(1);345 };346 return await this.helper.arrange.calculcateFee(address, wrappedCode);347 }348}349350class EthAddressGroup extends EthGroupBase {351 extractCollectionId(address: string): number {352 if (!(address.length === 42 || address.length === 40)) throw new Error('address wrong format');353 return parseInt(address.substr(address.length - 8), 16);354 }355356 fromCollectionId(collectionId: number): string {357 if (collectionId >= 0xffffffff || collectionId < 0) throw new Error('collectionId overflow');358 return Web3.utils.toChecksumAddress(`0x17c4e6453cc49aaaaeaca894e6d9683e${collectionId.toString(16).padStart(8, '0')}`);359 }360361 extractTokenId(address: string): {collectionId: number, tokenId: number} {362 if (!address.startsWith('0x'))363 throw 'address not starts with "0x"';364 if (address.length > 42)365 throw 'address length is more than 20 bytes';366 return {367 collectionId: Number('0x' + address.substring(address.length - 16, address.length - 8)),368 tokenId: Number('0x' + address.substring(address.length - 8)),369 };370 }371372 fromTokenId(collectionId: number, tokenId: number): string {373 return this.helper.util.getTokenAddress({collectionId, tokenId});374 }375376 normalizeAddress(address: string): string {377 return '0x' + address.substring(address.length - 40);378 }379}380export class EthPropertyGroup extends EthGroupBase {381 property(key: string, value: string): EthProperty {382 return [383 key,384 '0x'+Buffer.from(value).toString('hex'),385 ];386 }387}388export type EthUniqueHelperConstructor = new (...args: any[]) => EthUniqueHelper;389390export class EthCrossAccountGroup extends EthGroupBase {391 createAccount(): TEthCrossAccount {392 return this.fromAddress(this.helper.eth.createAccount());393 }394395 async createAccountWithBalance(donor: IKeyringPair, amount=100n) {396 return this.fromAddress(await this.helper.eth.createAccountWithBalance(donor, amount));397 }398399 fromAddress(address: TEthereumAccount): TEthCrossAccount {400 return {401 eth: address,402 sub: '0',403 };404 }405406 fromKeyringPair(keyring: IKeyringPair): TEthCrossAccount {407 return {408 eth: '0x0000000000000000000000000000000000000000',409 sub: keyring.addressRaw,410 };411 }412}413414export class EthUniqueHelper extends DevUniqueHelper {415 web3: Web3 | null = null;416 web3Provider: WebsocketProvider | null = null;417418 eth: EthGroup;419 ethAddress: EthAddressGroup;420 ethCrossAccount: EthCrossAccountGroup;421 ethNativeContract: NativeContractGroup;422 ethContract: ContractGroup;423 ethProperty: EthPropertyGroup;424425 constructor(logger: { log: (msg: any, level: any) => void, level: any }, options: {[key: string]: any} = {}) {426 options.helperBase = options.helperBase ?? EthUniqueHelper;427428 super(logger, options);429 this.eth = new EthGroup(this);430 this.ethAddress = new EthAddressGroup(this);431 this.ethCrossAccount = new EthCrossAccountGroup(this);432 this.ethNativeContract = new NativeContractGroup(this);433 this.ethContract = new ContractGroup(this);434 this.ethProperty = new EthPropertyGroup(this);435 }436437 getWeb3(): Web3 {438 if(this.web3 === null) throw Error('Web3 not connected');439 return this.web3;440 }441442 connectWeb3(wsEndpoint: string) {443 if(this.web3 !== null) return;444 this.web3Provider = new Web3.providers.WebsocketProvider(wsEndpoint);445 this.web3 = new Web3(this.web3Provider);446 }447448 async disconnect() {449 if(this.web3 === null) return;450 this.web3Provider?.connection.close();451452 await super.disconnect();453 }454455 clearApi() {456 super.clearApi();457 this.web3 = null;458 }459460 clone(helperCls: EthUniqueHelperConstructor, options?: { [key: string]: any; }): EthUniqueHelper {461 const newHelper = super.clone(helperCls, options) as EthUniqueHelper;462 newHelper.web3 = this.web3;463 newHelper.web3Provider = this.web3Provider;464465 return newHelper;466 }467}